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>
fd0e21a parent: aabacba modified
BUCK +24 -4 | @@ -21,10 +21,9 @@ filegroup( | ||
| 21 | 21 | visibility = ["PUBLIC"], |
| 22 | 22 | ) |
| 23 | 23 | |
| 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. | |
| 28 | 27 | configured_alias( |
| 29 | 28 | name = "vidya-ffi-android", |
| 30 | 29 | actual = "//crates/jolt-vidya:vidya-ffi", |
| @@ -38,3 +37,24 @@ genrule( | ||
| 38 | 37 | cmd = "cp $(location :vidya-ffi-android[shared]) $OUT", |
| 39 | 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 | +) | |
| @@ -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 are | 24 | +# 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 what | 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 | 26 | +# 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 = [ | ||
| 3242 | 3242 | "env_logger", |
| 3243 | 3243 | "image", |
| 3244 | 3244 | "iroh-live", |
| 3245 | + "jni 0.22.4", | |
| 3245 | 3246 | "jolt-abi", |
| 3246 | 3247 | "libc", |
| 3247 | 3248 | "log", |
| 3248 | 3249 | "moq-lite", |
| 3249 | 3250 | "moq-native", |
| 3250 | 3251 | "n0-future", |
| 3252 | + "ndk-context", | |
| 3251 | 3253 | "rand 0.8.7", |
| 3252 | 3254 | "rustls", |
| 3253 | 3255 | "tokio", |
| @@ -5698,6 +5700,7 @@ dependencies = [ | ||
| 5698 | 5700 | "image", |
| 5699 | 5701 | "libc", |
| 5700 | 5702 | "n0-future", |
| 5703 | + "ndk", | |
| 5701 | 5704 | "openh264", |
| 5702 | 5705 | "pic-scale", |
| 5703 | 5706 | "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: | ||
| 97 | 97 | |
| 98 | 98 | ### Its relationship to sleek |
| 99 | 99 | |
| 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 | |
| 102 | 102 | two by eye. What was dropped on the way was the signaling-and-presentation |
| 103 | 103 | half — `ChannelCall`, `LocalCall`, `apply_av_state`, `av_state_message` — |
| 104 | 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 | 108 | next step and has not been taken yet; until it is, a fix made in one place |
| 109 | 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 | 118 | ## vidya |
| 112 | 119 | |
| 113 | 120 | 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 sleek | 98 | ### Its relationship to sleek |
| 99 | 99 | ||
| 100 | -`av.rs`, `av_media.rs` and `v4l2cam.rs` came from sleek's `android/src`. They | 100 | +`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 the | 101 | +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-presentation | 102 | 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 place | 108 | 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 | ## vidya | 118 | ## vidya |
| 112 | 119 | ||
| 113 | The theme layer, the widget set and the retained-tree ABI that glimmer paints | 120 | 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 @@ | ||
| 8 | 8 | * `vidya_jolt_main`, so the entry point below is an ordinary C function rather |
| 9 | 9 | * than a `main`. |
| 10 | 10 | * |
| 11 | - * Every Vidya symbol is registered with Chez explicitly. Jolt resolves a | |
| 11 | + * Every foreign symbol is registered with Chez explicitly. Jolt resolves a | |
| 12 | 12 | * `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. | |
| 17 | 23 | */ |
| 18 | 24 | |
| 19 | 25 | #include <stddef.h> |
| @@ -23,6 +29,7 @@ | ||
| 23 | 29 | #include <android/log.h> |
| 24 | 30 | #include <pthread.h> |
| 25 | 31 | |
| 32 | +#include "joltmoq.h" | |
| 26 | 33 | #include "scheme.h" |
| 27 | 34 | #include "vidya.h" |
| 28 | 35 | #include "vidya_tree.h" |
| @@ -32,6 +39,7 @@ extern const unsigned char _binary_jolt_boot_start[]; | ||
| 32 | 39 | extern const unsigned char _binary_jolt_boot_end[]; |
| 33 | 40 | |
| 34 | 41 | #define REGISTER_VIDYA(name) Sregister_symbol(#name, (void *)&name) |
| 42 | +#define REGISTER_JOLTMOQ(name) Sregister_symbol(#name, (void *)&name) | |
| 35 | 43 | |
| 36 | 44 | static void register_vidya_api(void) { |
| 37 | 45 | /* vidya.h */ |
| @@ -106,6 +114,57 @@ static void register_vidya_api(void) { | ||
| 106 | 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 | 169 | * Chez writes to stdout/stderr, which on Android goes nowhere. Pump both into |
| 111 | 170 | * logcat so a Scheme-level error is visible rather than a silent exit. |
| @@ -145,6 +204,23 @@ int vidya_jolt_main(void) { | ||
| 145 | 204 | (iptr)(_binary_jolt_boot_end - _binary_jolt_boot_start)); |
| 146 | 205 | Sbuild_heap(NULL, NULL); |
| 147 | 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 | 225 | __android_log_print(ANDROID_LOG_INFO, "VidyaJolt", "starting Jolt application"); |
| 150 | 226 | int status = Sscheme_start(1, argv); |
| @@ -8,12 +8,18 @@ | |||
| 8 | * `vidya_jolt_main`, so the entry point below is an ordinary C function rather | 8 | * `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 a | 11 | + * Every foreign symbol is registered with Chez explicitly. Jolt resolves a |
| 12 | * `defcfn` through Chez's foreign-entry table, and on Android nothing populates | 12 | * `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 | 13 | + * 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 case | 14 | + * 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 both | 15 | + * 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 into | 169 | * 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 @@ | ||
| 1 | 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 | 8 | rust_library( |
| 3 | 9 | name = "jolt-moq", |
| 4 | 10 | srcs = glob(["src/**/*.rs"]), |
| @@ -8,10 +14,16 @@ rust_library( | ||
| 8 | 14 | preferred_linkage = "shared", |
| 9 | 15 | soname = "libjoltmoq.so", |
| 10 | 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 | 24 | deps = [ |
| 12 | 25 | "//crates/jolt-abi:jolt-abi", |
| 13 | 26 | "//third-party/rust:anyhow", |
| 14 | - "//third-party/rust/cpal:cpal", | |
| 15 | 27 | "//third-party/rust:env_logger", |
| 16 | 28 | "//third-party/rust:image", |
| 17 | 29 | "//third-party/rust:iroh-live", |
| @@ -25,6 +37,17 @@ rust_library( | ||
| 25 | 37 | "//third-party/rust:tokio", |
| 26 | 38 | "//third-party/rust:url", |
| 27 | 39 | "//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 | + }), | |
| 30 | 53 | ) |
| @@ -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 | ||
| 32 | 32 | iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [ |
| 33 | 33 | "opus", |
| 34 | 34 | "h264", |
| 35 | - "capture-camera", | |
| 36 | 35 | ] } |
| 37 | 36 | moq-native = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main", default-features = false, features = [ |
| 38 | 37 | "noq", |
| @@ -42,9 +41,6 @@ moq-native = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main", | ||
| 42 | 41 | moq-lite = { git = "https://github.com/Frando/moq", branch = "deps/iroh-main" } |
| 43 | 42 | # aws-lc-rs rather than ring, to match what moq-native and iroh already pull in. |
| 44 | 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 | 44 | # JPEG only — the V4L2 path converts MJPEG frames, and nothing here reads or |
| 49 | 45 | # writes a picture file, so the rest of the format set is dead weight. |
| 50 | 46 | image = { version = "0.25", default-features = false, features = ["jpeg"] } |
| @@ -57,3 +53,34 @@ anyhow = "1" | ||
| 57 | 53 | url = "2" |
| 58 | 54 | rand = "0.8" |
| 59 | 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 | +] } | |
| @@ -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 or | 44 | # 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" { | ||
| 36 | 36 | * about why, and says none of it otherwise. */ |
| 37 | 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 | 56 | /* Lifecycle |
| 40 | 57 | * |
| 41 | 58 | * `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 | /* Lifecycle | 56 | /* 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 @@ | ||
| 36 | 36 | |
| 37 | 37 | pub mod av; |
| 38 | 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 | 47 | mod v4l2cam; |
| 48 | +#[cfg(target_os = "android")] | |
| 49 | +mod android_camera; | |
| 50 | +#[cfg(target_os = "android")] | |
| 51 | +mod android_jni; | |
| 40 | 52 | |
| 41 | 53 | use std::collections::HashMap; |
| 42 | 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 | 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 | 182 | /// Turn on logging to stderr, honouring `RUST_LOG`. Safe to call twice. |
| 138 | 183 | /// |
| 139 | 184 | /// 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 a | 184 | /// 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); | ||
| 68 | 68 | VIDYA_API void vidya_status(const char *label, int live); |
| 69 | 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 | 80 | #ifdef __cplusplus |
| 72 | 81 | } |
| 73 | 82 | #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 __cplusplus | 80 | #ifdef __cplusplus |
| 72 | } | 81 | } |
| 73 | #endif | 82 | #endif |
modified
crates/jolt-vidya/src/android.rs +31 -0 | @@ -62,6 +62,37 @@ pub fn android_app() -> Option<AndroidApp> { | ||
| 62 | 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 | 96 | extern "C" { |
| 66 | 97 | fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; |
| 67 | 98 | 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: | ||
| 49 | 49 | buck-libdir: |
| 50 | 50 | @echo "{{justfile_directory()}}/build/lib" |
| 51 | 51 | |
| 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. | |
| 55 | 58 | # |
| 56 | 59 | # The NDK is fetched by buck2 from the pin in scripts/android-ndk.dotslash, so |
| 57 | 60 | # the only thing a machine needs installed for this is nothing. |
| 58 | 61 | ffi-android: |
| 59 | - @just buck build //:libvidya-android | |
| 62 | + @just buck build //:libs-android | |
| 60 | 63 | mkdir -p build/android/arm64-v8a |
| 61 | 64 | cp "$(just buck build --show-output //:libvidya-android | awk '/libvidya.so/{print $2}')" \ |
| 62 | 65 | 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 | 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 | 71 | android-libdir: |
| 67 | 72 | @echo "{{justfile_directory()}}/build/android/arm64-v8a" |
| 68 | 73 | |
| @@ -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 an | 52 | +# 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 is | 53 | +# 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, so | 59 | # 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-android | 62 | + @just buck build //:libs-android |
| 60 | mkdir -p build/android/arm64-v8a | 63 | 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.so | 65 | 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( | ||
| 23 | 23 | remote_enabled = True, |
| 24 | 24 | remote_execution_properties = { |
| 25 | 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 | 33 | use_windows_path_separators = host_info().os.is_windows, |
| 29 | 34 | 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( | ||
| 1933 | 1933 | "OPT_LEVEL": "2", |
| 1934 | 1934 | }, |
| 1935 | 1935 | features = ["prebuilt-nasm"], |
| 1936 | + rustc_link_lib = True, | |
| 1937 | + rustc_link_search = True, | |
| 1936 | 1938 | version = "0.43.0", |
| 1937 | 1939 | ) |
| 1938 | 1940 | |
| @@ -11711,10 +11713,26 @@ cargo.rust_library( | ||
| 11711 | 11713 | "CARGO_PKG_VERSION_PRE": "", |
| 11712 | 11714 | }, |
| 11713 | 11715 | features = [ |
| 11714 | - "capture-camera", | |
| 11715 | 11716 | "h264", |
| 11716 | 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 | 11736 | visibility = [], |
| 11719 | 11737 | deps = [ |
| 11720 | 11738 | ":anyhow-1", |
| @@ -14022,10 +14040,30 @@ cargo.rust_library( | ||
| 14022 | 14040 | "OUT_DIR": "$(location :moq-media-0.1-build-script-run[out_dir])", |
| 14023 | 14041 | }, |
| 14024 | 14042 | features = [ |
| 14025 | - "capture-camera", | |
| 14026 | 14043 | "h264", |
| 14027 | 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 | 14067 | rustc_flags = ["@$(location :moq-media-0.1-build-script-run[rustc_flags])"], |
| 14030 | 14068 | visibility = [], |
| 14031 | 14069 | deps = [ |
| @@ -14042,7 +14080,6 @@ cargo.rust_library( | ||
| 14042 | 14080 | ":n0-watcher-0.6", |
| 14043 | 14081 | ":ringbuf-0.4", |
| 14044 | 14082 | ":rubato-1", |
| 14045 | - ":rusty-capture-0.1", | |
| 14046 | 14083 | ":rusty-codecs-0.1", |
| 14047 | 14084 | ":sonora-0.1", |
| 14048 | 14085 | ":strum-0.28", |
| @@ -14079,10 +14116,26 @@ cargo.rust_binary( | ||
| 14079 | 14116 | "CARGO_PKG_VERSION_PRE": "", |
| 14080 | 14117 | }, |
| 14081 | 14118 | features = [ |
| 14082 | - "capture-camera", | |
| 14083 | 14119 | "h264", |
| 14084 | 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 | 14139 | visibility = [], |
| 14087 | 14140 | ) |
| 14088 | 14141 | |
| @@ -14104,11 +14157,27 @@ buildscript_run( | ||
| 14104 | 14157 | "OPT_LEVEL": "2", |
| 14105 | 14158 | }, |
| 14106 | 14159 | features = [ |
| 14107 | - "capture-camera", | |
| 14108 | 14160 | "h264", |
| 14109 | 14161 | "opus", |
| 14110 | 14162 | ], |
| 14111 | 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 | 14181 | version = "0.1.0", |
| 14113 | 14182 | ) |
| 14114 | 14183 | |
| @@ -14575,6 +14644,8 @@ cargo.rust_library( | ||
| 14575 | 14644 | "api-level-25", |
| 14576 | 14645 | "api-level-26", |
| 14577 | 14646 | "audio", |
| 14647 | + "default", | |
| 14648 | + "media", | |
| 14578 | 14649 | "rwh_06", |
| 14579 | 14650 | ], |
| 14580 | 14651 | named_deps = { |
| @@ -14662,7 +14733,10 @@ cargo.rust_library( | ||
| 14662 | 14733 | "CARGO_PKG_VERSION_PATCH": "0", |
| 14663 | 14734 | "CARGO_PKG_VERSION_PRE": "", |
| 14664 | 14735 | }, |
| 14665 | - features = ["audio"], | |
| 14736 | + features = [ | |
| 14737 | + "audio", | |
| 14738 | + "media", | |
| 14739 | + ], | |
| 14666 | 14740 | visibility = [], |
| 14667 | 14741 | deps = [":jni-sys-0.3"], |
| 14668 | 14742 | ) |
| @@ -18374,6 +18448,8 @@ buildscript_run( | ||
| 18374 | 18448 | "OPT_LEVEL": "2", |
| 18375 | 18449 | }, |
| 18376 | 18450 | features = ["source"], |
| 18451 | + rustc_link_lib = True, | |
| 18452 | + rustc_link_search = True, | |
| 18377 | 18453 | version = "0.9.7", |
| 18378 | 18454 | ) |
| 18379 | 18455 | |
| @@ -22771,6 +22847,12 @@ cargo.rust_library( | ||
| 22771 | 22847 | "hang", |
| 22772 | 22848 | "opus", |
| 22773 | 22849 | ], |
| 22850 | + platform = { | |
| 22851 | + "android-arm64": dict( | |
| 22852 | + features = ["android"], | |
| 22853 | + deps = [":ndk-0.9"], | |
| 22854 | + ), | |
| 22855 | + }, | |
| 22774 | 22856 | rustc_flags = ["@$(location :rusty-codecs-0.1-build-script-run[rustc_flags])"], |
| 22775 | 22857 | visibility = [], |
| 22776 | 22858 | deps = [ |
| @@ -22822,6 +22904,11 @@ cargo.rust_binary( | ||
| 22822 | 22904 | "hang", |
| 22823 | 22905 | "opus", |
| 22824 | 22906 | ], |
| 22907 | + platform = { | |
| 22908 | + "android-arm64": dict( | |
| 22909 | + features = ["android"], | |
| 22910 | + ), | |
| 22911 | + }, | |
| 22825 | 22912 | visibility = [], |
| 22826 | 22913 | ) |
| 22827 | 22914 | |
| @@ -22848,6 +22935,11 @@ buildscript_run( | ||
| 22848 | 22935 | "opus", |
| 22849 | 22936 | ], |
| 22850 | 22937 | manifest_dir = ":iroh-live-cba230b1e993c078.git[rusty-codecs]", |
| 22938 | + platform = { | |
| 22939 | + "android-arm64": dict( | |
| 22940 | + features = ["android"], | |
| 22941 | + ), | |
| 22942 | + }, | |
| 22851 | 22943 | version = "0.1.0", |
| 22852 | 22944 | ) |
| 22853 | 22945 | |
| @@ -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 = [ | ||
| 3336 | 3336 | "glutin-winit", |
| 3337 | 3337 | "image", |
| 3338 | 3338 | "iroh-live", |
| 3339 | + "jni 0.21.1", | |
| 3339 | 3340 | "jni 0.22.4", |
| 3340 | 3341 | "libc", |
| 3341 | 3342 | "log", |
| @@ -3344,6 +3345,10 @@ dependencies = [ | ||
| 3344 | 3345 | "moq-native", |
| 3345 | 3346 | "mp4", |
| 3346 | 3347 | "n0-future", |
| 3348 | + "ndk", | |
| 3349 | + "ndk-context", | |
| 3350 | + "num-derive", | |
| 3351 | + "num-traits", | |
| 3347 | 3352 | "objc2 0.6.4", |
| 3348 | 3353 | "objc2-audio-toolbox", |
| 3349 | 3354 | "objc2-core-audio", |
| @@ -5858,6 +5863,7 @@ dependencies = [ | ||
| 5858 | 5863 | "image", |
| 5859 | 5864 | "libc", |
| 5860 | 5865 | "n0-future", |
| 5866 | + "ndk", | |
| 5861 | 5867 | "openh264", |
| 5862 | 5868 | "pic-scale", |
| 5863 | 5869 | "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" | ||
| 44 | 44 | # JPEG only — the V4L2 path converts MJPEG frames, and nothing here reads or |
| 45 | 45 | # writes a picture file. |
| 46 | 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 | 50 | iroh-live = { git = "https://github.com/n0-computer/iroh-live", rev = "edd9bcc530a615f3ab3a6919e96790a077905376", default-features = false, features = [ |
| 48 | 51 | "opus", |
| 49 | 52 | "h264", |
| 50 | - "capture-camera", | |
| 51 | 53 | ] } |
| 52 | 54 | libc = "0.2" |
| 53 | 55 | log = "0.4" |
| @@ -93,16 +95,48 @@ objc2-core-audio-types = { version = "0.3", default-features = false, features = | ||
| 93 | 95 | objc2-core-foundation = "0.3" |
| 94 | 96 | objc2-foundation = "0.3" |
| 95 | 97 | |
| 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. | |
| 98 | 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 | 116 | # `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 | 119 | # Renamed because cpal wants jni 0.21 on the same platform, and cpal is a path |
| 103 | 120 | # dependency above — so reindeer gives its deps public aliases too, and two |
| 104 | 121 | # crates called `jni` cannot both have one. The consumer says `named_deps`. |
| 105 | 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 | 140 | # The feature is what pulls in android-activity, and android-activity is what |
| 107 | 141 | # owns the event loop: the APK's NativeActivity has no Java main to call into, |
| 108 | 142 | # 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 or | 44 | # 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 against | 116 | # `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 path | 119 | # 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 two | 120 | # 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 what | 140 | # 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 of | 142 | # 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( | ||
| 18 | 18 | "//third-party/rust:alsa", |
| 19 | 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 | 32 | # CoreAudio. The objc2 rows are pinned to the versions cpal asks for |
| 22 | 33 | # rather than the older ones winit drags into the same graph. |
| 23 | 34 | "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 for | 32 | # 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 @@ | ||
| 1 | 1 | [buildscript.run] |
| 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 | |
| @@ -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 @@ | ||
| 3 | 3 | # cargo sets OPT_LEVEL for every build script; buck2 does not, and cc-rs and |
| 4 | 4 | # a number of configure scripts read it unconditionally. |
| 5 | 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 | |
| @@ -3,3 +3,12 @@ | |||
| 3 | # cargo sets OPT_LEVEL for every build script; buck2 does not, and cc-rs and | 3 | # 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 | ||
| 44 | 44 | # archive, so nothing depends on which permissions survived the unpacking. |
| 45 | 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 | 53 | 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" ;; | |
| 50 | 56 | ar) exec "$bin/llvm-ar" "$@" ;; |
| 51 | 57 | ranlib) exec "$bin/llvm-ranlib" "$@" ;; |
| 52 | 58 | 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 in | 53 | 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" "$@" ;; |