whisker-driver-sys 0.6.0

Raw FFI + C++ bridge sources for the Whisker driver. Internal — use `whisker-driver` for the safe wrappers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
// whisker_bridge_android.cc
//
// Android-specific glue: extracts the LynxShell from a Java LynxView and
// drives the host-wake-up callback through JNI back into Kotlin. All actual
// Element PAPI work lives in whisker_bridge_common.cc.
//
// The whole file is gated on `__ANDROID__` so it compiles to nothing on
// non-Android platforms. Whisker's Cargo build scripts already select per
// platform, but the guard is defense-in-depth: any future build system
// (CMake / Bazel / Xcode) that scans this directory wholesale will get
// an empty translation unit on iOS / macOS / Linux instead of a
// `<jni.h> not found` failure.

#if defined(__ANDROID__)

#include <jni.h>
#include <android/log.h>
#include <dlfcn.h>

#include <atomic>
#include <cstdint>
#include <map>
#include <mutex>

#include "whisker_bridge.h"
#include "whisker_bridge_internal.h"

#define LOG_TAG "WhiskerBridge"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)

namespace {

// Cached JVM + the Kotlin object/method we need to call back into for
// the "wake the render loop" path. Set lazily on the first attach so we
// don't pay reflection cost more than once.
struct JvmHandles {
    JavaVM* jvm = nullptr;
    jclass whisker_view_class = nullptr;        // global ref
    jmethodID request_frame_method = nullptr; // void requestFrameFromNative()
};

JvmHandles& Handles() {
    static JvmHandles h;
    return h;
}

// Stored per-engine: the Kotlin WhiskerView weak global ref we call back into.
// Held in a side map keyed by engine pointer so the common code doesn't
// need to know about Java.
struct EngineJavaState {
    jobject whisker_view_weak = nullptr;  // weak global ref
};

std::mutex& JavaStateMutex() {
    static std::mutex m;
    return m;
}
std::map<WhiskerEngine*, EngineJavaState*>& JavaStateMap() {
    static std::map<WhiskerEngine*, EngineJavaState*> m;
    return m;
}
EngineJavaState* LookupJavaState(WhiskerEngine* engine) {
    auto& m = JavaStateMap();
    auto it = m.find(engine);
    return it == m.end() ? nullptr : it->second;
}

// Extract the raw LynxShell pointer from a Java LynxView via:
//   LynxView.mLynxTemplateRender   (protected LynxTemplateRender)
//     → LynxTemplateRender.mNativePtr   (private long; jlong-cast pointer)
//
// We deliberately do NOT cast to `lynx::shell::LynxShell*` here — the
// bridge no longer pulls in Lynx C++ headers; the void* gets handed
// to `lynx_shell_from_native_ptr` (defined inside liblynx.so) which
// does the cast on the Lynx side.
void* ExtractShell(JNIEnv* env, jobject lynx_view) {
    if (env == nullptr || lynx_view == nullptr) {
        LOGE("ExtractShell: env or lynx_view is null");
        return nullptr;
    }
    // GetObjectClass returns the runtime class (WhiskerView), but a JNI
    // GetFieldID call looks up inherited fields too.
    jclass view_class = env->GetObjectClass(lynx_view);
    if (view_class == nullptr) {
        LOGE("ExtractShell: GetObjectClass returned null");
        return nullptr;
    }
    // Belt-and-suspenders: also look up the *declaring* class to make
    // sure the lookup hits regardless of how JNI handles inherited
    // protected fields with obfuscation toolchains.
    jclass lynx_view_class = env->FindClass("com/lynx/tasm/LynxView");
    if (lynx_view_class == nullptr) {
        if (env->ExceptionCheck()) env->ExceptionClear();
        LOGE("ExtractShell: FindClass com/lynx/tasm/LynxView failed");
        env->DeleteLocalRef(view_class);
        return nullptr;
    }
    jfieldID render_field = env->GetFieldID(
        lynx_view_class, "mLynxTemplateRender",
        "Lcom/lynx/tasm/LynxTemplateRender;");
    env->DeleteLocalRef(view_class);
    env->DeleteLocalRef(lynx_view_class);
    if (env->ExceptionCheck()) {
        env->ExceptionDescribe();
        env->ExceptionClear();
        LOGE("ExtractShell: GetFieldID(mLynxTemplateRender) failed");
        return nullptr;
    }
    if (render_field == nullptr) {
        LOGE("ExtractShell: render_field is nullptr (field not found?)");
        return nullptr;
    }
    jobject render = env->GetObjectField(lynx_view, render_field);
    if (render == nullptr) {
        LOGE("ExtractShell: mLynxTemplateRender is null on instance");
        return nullptr;
    }
    jclass render_class = env->FindClass("com/lynx/tasm/LynxTemplateRender");
    if (render_class == nullptr) {
        if (env->ExceptionCheck()) env->ExceptionClear();
        LOGE("ExtractShell: FindClass com/lynx/tasm/LynxTemplateRender failed");
        env->DeleteLocalRef(render);
        return nullptr;
    }
    jfieldID native_ptr_field = env->GetFieldID(render_class, "mNativePtr", "J");
    env->DeleteLocalRef(render_class);
    if (env->ExceptionCheck()) {
        env->ExceptionDescribe();
        env->ExceptionClear();
        LOGE("ExtractShell: GetFieldID(mNativePtr) failed");
        env->DeleteLocalRef(render);
        return nullptr;
    }
    if (native_ptr_field == nullptr) {
        LOGE("ExtractShell: native_ptr_field is nullptr");
        env->DeleteLocalRef(render);
        return nullptr;
    }
    jlong native = env->GetLongField(render, native_ptr_field);
    env->DeleteLocalRef(render);
    return reinterpret_cast<void*>(static_cast<intptr_t>(native));
}

// Temporary diagnostic — log a single integer + tag from Rust callers
// so we can see whether the native item-provider trampoline fires.
extern "C" void whisker_bridge_debug_log_i32(const char* tag, int32_t value) {
    __android_log_print(ANDROID_LOG_ERROR,
                        tag != nullptr ? tag : "WHISKER",
                        "diag value=%d", value);
}

// Trampoline the Rust runtime calls when a signal update needs a frame.
// `user_data` is a `WhiskerEngine*` we keep paired with the Kotlin view in
// the JavaStateFor map.
extern "C" void RequestFrameTrampoline(void* user_data) {
    auto* engine = static_cast<WhiskerEngine*>(user_data);
    JvmHandles& handles = Handles();
    if (handles.jvm == nullptr || handles.request_frame_method == nullptr) return;

    EngineJavaState* state = nullptr;
    {
        std::lock_guard<std::mutex> lock(JavaStateMutex());
        state = LookupJavaState(engine);
    }
    if (state == nullptr || state->whisker_view_weak == nullptr) return;

    JNIEnv* env = nullptr;
    bool attached = false;
    if (handles.jvm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6)
            != JNI_OK) {
        if (handles.jvm->AttachCurrentThread(&env, nullptr) != JNI_OK) {
            LOGE("RequestFrame: failed to attach thread to JVM");
            return;
        }
        attached = true;
    }
    jobject view = env->NewLocalRef(state->whisker_view_weak);
    if (view != nullptr) {
        env->CallVoidMethod(view, handles.request_frame_method);
        if (env->ExceptionCheck()) {
            env->ExceptionDescribe();
            env->ExceptionClear();
        }
        env->DeleteLocalRef(view);
    }
    if (attached) {
        handles.jvm->DetachCurrentThread();
    }
}

}  // namespace

// JNI_OnLoad — cache the JVM pointer + WhiskerView class/method handles.
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* /*reserved*/) {
    JvmHandles& handles = Handles();
    handles.jvm = vm;

    JNIEnv* env = nullptr;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }
    jclass local = env->FindClass("rs/whisker/runtime/WhiskerView");
    if (local == nullptr) {
        LOGE("JNI_OnLoad: rs/whisker/runtime/WhiskerView not found");
        return JNI_ERR;
    }
    handles.whisker_view_class = static_cast<jclass>(env->NewGlobalRef(local));
    handles.request_frame_method = env->GetMethodID(
        handles.whisker_view_class, "requestFrameFromNative", "()V");
    env->DeleteLocalRef(local);
    if (handles.request_frame_method == nullptr) {
        LOGE("JNI_OnLoad: requestFrameFromNative not found on WhiskerView");
        return JNI_ERR;
    }
    return JNI_VERSION_1_6;
}

// rs.whisker.runtime.WhiskerView.nativeEngineAttach
extern "C" JNIEXPORT jlong JNICALL
Java_rs_whisker_runtime_WhiskerView_nativeEngineAttach(
    JNIEnv* env, jobject /*self*/, jobject lynx_view) {
    void* native_shell_ptr = ExtractShell(env, lynx_view);
    if (native_shell_ptr == nullptr) {
        LOGE("nativeEngineAttach: could not extract LynxShell* from LynxView");
        return 0;
    }
    WhiskerEngine* engine = whisker_bridge_internal_engine_create(native_shell_ptr);
    if (engine == nullptr) {
        LOGE("nativeEngineAttach: whisker_bridge_internal_engine_create failed");
        return 0;
    }
    return reinterpret_cast<jlong>(engine);
}

// rs.whisker.runtime.WhiskerView.nativeBindWhiskerView
//
// Pairs the WhiskerEngine with the Kotlin WhiskerView that owns it so the
// `request_frame` trampoline can call back into Kotlin's render-loop
// pause/unpause logic.
extern "C" JNIEXPORT void JNICALL
Java_rs_whisker_runtime_WhiskerView_nativeBindWhiskerView(
    JNIEnv* env, jobject self, jlong engine_raw) {
    auto* engine = reinterpret_cast<WhiskerEngine*>(engine_raw);
    if (engine == nullptr) return;
    auto* state = new EngineJavaState();
    state->whisker_view_weak = env->NewWeakGlobalRef(self);
    std::lock_guard<std::mutex> lock(JavaStateMutex());
    JavaStateMap()[engine] = state;
}

// rs.whisker.runtime.WhiskerView.nativeRequestFrameCallback
//
// Returns the C function pointer + user_data pair that Rust should call
// when signals dirty. Bundled into a small (fn, data) tuple via two
// jlong returns isn't great, so we use a dedicated init entry point —
// the caller (Kotlin) just hands us the engine and we wire it up.
//
// Used by WhiskerView right after nativeEngineAttach.
extern "C" JNIEXPORT void JNICALL
Java_rs_whisker_runtime_WhiskerView_nativeEngineRelease(
    JNIEnv* env, jobject /*self*/, jlong engine_raw) {
    auto* engine = reinterpret_cast<WhiskerEngine*>(engine_raw);
    if (engine == nullptr) return;
    {
        std::lock_guard<std::mutex> lock(JavaStateMutex());
        EngineJavaState* state = LookupJavaState(engine);
        if (state != nullptr) {
            if (state->whisker_view_weak != nullptr) {
                env->DeleteWeakGlobalRef(state->whisker_view_weak);
            }
            delete state;
            JavaStateMap().erase(engine);
        }
    }
    whisker_bridge_engine_release(engine);
}

// Exposed to Kotlin so it can hand the trampoline + engine pair to the
// Rust runtime's `whisker_app_main`. Returning the function pointer
// directly as jlong keeps the bridge ABI tidy on the Kotlin side.
extern "C" JNIEXPORT jlong JNICALL
Java_rs_whisker_runtime_WhiskerView_nativeRequestFrameFnPtr(
    JNIEnv* /*env*/, jclass /*clazz*/) {
    return reinterpret_cast<jlong>(&RequestFrameTrampoline);
}

// Rust runtime entry points. Defined by the user's `#[whisker::main]` crate
// (e.g. examples/hello-world); on Android both these symbols and the
// bridge code below land in the same .so (build.rs compiles the bridge
// straight into the cdylib), so we can just call them directly — no
// dlsym dance needed.
extern "C" void whisker_app_main(void* engine,
                              void (*request_frame)(void*),
                              void* request_frame_data);
extern "C" bool whisker_tick(void* engine);

extern "C" JNIEXPORT void JNICALL
Java_rs_whisker_runtime_WhiskerView_nativeAppMain(
    JNIEnv* /*env*/, jobject /*self*/, jlong engine_raw) {
    whisker_app_main(reinterpret_cast<void*>(engine_raw),
                  &RequestFrameTrampoline,
                  reinterpret_cast<void*>(engine_raw));
}

extern "C" JNIEXPORT jboolean JNICALL
Java_rs_whisker_runtime_WhiskerView_nativeTick(
    JNIEnv* /*env*/, jobject /*self*/, jlong engine_raw) {
    return whisker_tick(reinterpret_cast<void*>(engine_raw))
        ? JNI_TRUE : JNI_FALSE;
}

// Called from Kotlin's EventEmitter.LynxEventReporter hook for every
// LynxEvent the engine fires. We look up the (element_sign, event_name)
// pair in the bridge's callback registry; if a Rust closure was
// registered for it, fire and report consumed.
//
// `engine_raw` isn't strictly needed today (the registry is global) but
// kept in the signature so future per-engine registries don't require
// an ABI break.
// `Java_..._nativeOnLynxEvent` is defined below, after the
// `jobject_to_value` marshaller it depends on (which lives in the
// anonymous namespace).

// ---- Native module invocation (Phase 7-Φ.F, Android) ---------------------
//
// `whisker_bridge_invoke_module` routes into Kotlin's
// `WhiskerModuleRegistry.invokeDispatch(...)` via JNI. The KSP
// processor generates per-module dispatch lambdas that the
// registry maps by name. Args/returns are typed `WhiskerValue`
// (sealed class hierarchy in Kotlin) — no Foundation / boxed-Java
// type marshalling.
//
// JNI handles for `WhiskerValue` subclasses + the registry's
// dispatch method are cached once per process on first call.

#include <cstring>
#include <cstdlib>
#include <string>
#include <utility>
#include <vector>

namespace {

WhiskerValueRaw MakeAndroidBridgeError(const char* message) {
    WhiskerValueRaw v;
    std::memset(&v, 0, sizeof(v));
    v.type = WHISKER_VALUE_ERROR;
    if (message != nullptr) {
        size_t len = std::strlen(message);
        char* buf = static_cast<char*>(std::malloc(len + 1));
        std::memcpy(buf, message, len + 1);
        v.v.s.ptr = buf;
        v.v.s.len = len;
    }
    return v;
}

struct WhiskerValueJni {
    bool ready = false;
    jclass base = nullptr;
    jclass null_cls = nullptr;
    jobject null_obj = nullptr;
    jclass bool_cls = nullptr;
    jmethodID bool_ctor = nullptr, bool_get = nullptr;
    jclass int_cls = nullptr;
    jmethodID int_ctor = nullptr, int_get = nullptr;
    jclass float_cls = nullptr;
    jmethodID float_ctor = nullptr, float_get = nullptr;
    jclass str_cls = nullptr;
    jmethodID str_ctor = nullptr, str_get = nullptr;
    jclass bytes_cls = nullptr;
    jmethodID bytes_ctor = nullptr, bytes_get = nullptr;
    jclass array_cls = nullptr;
    jmethodID array_ctor = nullptr, array_get = nullptr;
    jclass map_cls = nullptr;
    jmethodID map_ctor = nullptr, map_get = nullptr;
    jclass err_cls = nullptr;
    jmethodID err_ctor = nullptr, err_get_msg = nullptr;

    jclass arraylist_cls = nullptr;
    jmethodID arraylist_ctor = nullptr, arraylist_add = nullptr;
    jclass list_cls = nullptr;
    jmethodID list_size = nullptr, list_get = nullptr;
    jclass hashmap_cls = nullptr;
    jmethodID hashmap_ctor = nullptr, hashmap_put = nullptr;
    jclass map_iface = nullptr;
    jmethodID map_entry_set = nullptr;
    jclass set_cls = nullptr;
    jmethodID set_iterator = nullptr;
    jclass iter_cls = nullptr;
    jmethodID iter_has_next = nullptr, iter_next = nullptr;
    jclass map_entry_cls = nullptr;
    jmethodID map_entry_key = nullptr, map_entry_val = nullptr;

    // Generic Java boxing types — for marshalling a raw event-body
    // `Map<String, Any?>` (Boolean / Number / String / Map / List)
    // straight into `WhiskerValueRaw`, without a Kotlin `WhiskerValue`
    // intermediary (the event reporter lives in the `whisker-runtime`
    // Gradle module, which doesn't depend on the `module` project
    // where `WhiskerValue` is declared).
    jclass jbool_cls = nullptr;
    jmethodID jbool_value = nullptr;
    jclass jnumber_cls = nullptr;
    jmethodID jnumber_long = nullptr, jnumber_double = nullptr;
    jclass jdouble_cls = nullptr;
    jclass jfloat_cls = nullptr;
    jclass jstring_cls = nullptr;

    jclass registry_cls = nullptr;
    jmethodID registry_dispatch = nullptr;

    // Phase L-2c — event subscription routing. The C++ trampolines
    // call back into Kotlin via these handles so the per-Module
    // OnStart/OnStopObserving closures fire.
    jclass event_center_cls = nullptr;
    jmethodID event_center_fire_start = nullptr;
    jmethodID event_center_fire_stop = nullptr;
};

WhiskerValueJni& wvjni() {
    static WhiskerValueJni h;
    return h;
}

jclass make_global(JNIEnv* env, const char* path) {
    jclass local = env->FindClass(path);
    if (local == nullptr) return nullptr;
    auto g = reinterpret_cast<jclass>(env->NewGlobalRef(local));
    env->DeleteLocalRef(local);
    return g;
}

bool init_wvjni(JNIEnv* env) {
    auto& h = wvjni();
    if (h.ready) return true;
    h.base       = make_global(env, "rs/whisker/runtime/WhiskerValue");
    h.null_cls   = make_global(env, "rs/whisker/runtime/WhiskerValue$Null");
    h.bool_cls   = make_global(env, "rs/whisker/runtime/WhiskerValue$Bool");
    h.int_cls    = make_global(env, "rs/whisker/runtime/WhiskerValue$Int");
    h.float_cls  = make_global(env, "rs/whisker/runtime/WhiskerValue$Float");
    h.str_cls    = make_global(env, "rs/whisker/runtime/WhiskerValue$Str");
    h.bytes_cls  = make_global(env, "rs/whisker/runtime/WhiskerValue$Bytes");
    h.array_cls  = make_global(env, "rs/whisker/runtime/WhiskerValue$Array");
    h.map_cls    = make_global(env, "rs/whisker/runtime/WhiskerValue$Map");
    h.err_cls    = make_global(env, "rs/whisker/runtime/WhiskerValue$Err");
    h.arraylist_cls = make_global(env, "java/util/ArrayList");
    h.list_cls      = make_global(env, "java/util/List");
    h.hashmap_cls   = make_global(env, "java/util/HashMap");
    h.map_iface     = make_global(env, "java/util/Map");
    h.set_cls       = make_global(env, "java/util/Set");
    h.iter_cls      = make_global(env, "java/util/Iterator");
    h.map_entry_cls = make_global(env, "java/util/Map$Entry");
    h.registry_cls  = make_global(env, "rs/whisker/runtime/WhiskerModuleRegistry");
    if (h.base == nullptr || h.registry_cls == nullptr) return false;

    jfieldID null_field = env->GetStaticFieldID(
        h.null_cls, "INSTANCE", "Lrs/whisker/runtime/WhiskerValue$Null;");
    if (null_field != nullptr) {
        jobject local = env->GetStaticObjectField(h.null_cls, null_field);
        h.null_obj = env->NewGlobalRef(local);
        env->DeleteLocalRef(local);
    }

    h.bool_ctor = env->GetMethodID(h.bool_cls, "<init>", "(Z)V");
    h.bool_get  = env->GetMethodID(h.bool_cls, "getValue", "()Z");
    h.int_ctor  = env->GetMethodID(h.int_cls, "<init>", "(J)V");
    h.int_get   = env->GetMethodID(h.int_cls, "getValue", "()J");
    h.float_ctor = env->GetMethodID(h.float_cls, "<init>", "(D)V");
    h.float_get  = env->GetMethodID(h.float_cls, "getValue", "()D");
    h.str_ctor = env->GetMethodID(h.str_cls, "<init>", "(Ljava/lang/String;)V");
    h.str_get  = env->GetMethodID(h.str_cls, "getValue", "()Ljava/lang/String;");
    h.bytes_ctor = env->GetMethodID(h.bytes_cls, "<init>", "([B)V");
    h.bytes_get  = env->GetMethodID(h.bytes_cls, "getValue", "()[B");
    h.array_ctor = env->GetMethodID(h.array_cls, "<init>", "(Ljava/util/List;)V");
    h.array_get  = env->GetMethodID(h.array_cls, "getValue", "()Ljava/util/List;");
    h.map_ctor = env->GetMethodID(h.map_cls, "<init>", "(Ljava/util/Map;)V");
    h.map_get  = env->GetMethodID(h.map_cls, "getValue", "()Ljava/util/Map;");
    h.err_ctor = env->GetMethodID(h.err_cls, "<init>", "(Ljava/lang/String;)V");
    h.err_get_msg = env->GetMethodID(h.err_cls, "getMessage", "()Ljava/lang/String;");

    h.arraylist_ctor = env->GetMethodID(h.arraylist_cls, "<init>", "()V");
    h.arraylist_add  = env->GetMethodID(h.arraylist_cls, "add", "(Ljava/lang/Object;)Z");
    h.list_size = env->GetMethodID(h.list_cls, "size", "()I");
    h.list_get  = env->GetMethodID(h.list_cls, "get", "(I)Ljava/lang/Object;");
    h.hashmap_ctor = env->GetMethodID(h.hashmap_cls, "<init>", "()V");
    h.hashmap_put  = env->GetMethodID(h.hashmap_cls, "put",
        "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;");
    h.map_entry_set = env->GetMethodID(h.map_iface, "entrySet", "()Ljava/util/Set;");
    h.set_iterator  = env->GetMethodID(h.set_cls, "iterator", "()Ljava/util/Iterator;");
    h.iter_has_next = env->GetMethodID(h.iter_cls, "hasNext", "()Z");
    h.iter_next     = env->GetMethodID(h.iter_cls, "next", "()Ljava/lang/Object;");
    h.map_entry_key = env->GetMethodID(h.map_entry_cls, "getKey", "()Ljava/lang/Object;");
    h.map_entry_val = env->GetMethodID(h.map_entry_cls, "getValue", "()Ljava/lang/Object;");

    h.jbool_cls   = make_global(env, "java/lang/Boolean");
    h.jnumber_cls = make_global(env, "java/lang/Number");
    h.jdouble_cls = make_global(env, "java/lang/Double");
    h.jfloat_cls  = make_global(env, "java/lang/Float");
    h.jstring_cls = make_global(env, "java/lang/String");
    if (h.jbool_cls != nullptr) {
        h.jbool_value = env->GetMethodID(h.jbool_cls, "booleanValue", "()Z");
    }
    if (h.jnumber_cls != nullptr) {
        h.jnumber_long   = env->GetMethodID(h.jnumber_cls, "longValue", "()J");
        h.jnumber_double = env->GetMethodID(h.jnumber_cls, "doubleValue", "()D");
    }

    h.registry_dispatch = env->GetStaticMethodID(
        h.registry_cls, "invokeDispatch",
        "(Ljava/lang/String;Ljava/lang/String;[Lrs/whisker/runtime/WhiskerValue;)"
        "Lrs/whisker/runtime/WhiskerValue;");
    if (h.registry_dispatch == nullptr) {
        if (env->ExceptionCheck()) env->ExceptionClear();
        return false;
    }

    // Phase L-2c — `WhiskerModuleEventCenter` handles for the
    // observer-hook trampolines (fireStart / fireStop).
    h.event_center_cls = make_global(
        env, "rs/whisker/runtime/WhiskerModuleEventCenter");
    if (h.event_center_cls != nullptr) {
        h.event_center_fire_start = env->GetStaticMethodID(
            h.event_center_cls, "fireStart",
            "(Ljava/lang/String;Ljava/lang/String;)V");
        h.event_center_fire_stop = env->GetStaticMethodID(
            h.event_center_cls, "fireStop",
            "(Ljava/lang/String;Ljava/lang/String;)V");
        // Don't hard-fail if the class isn't present yet — a host
        // app that doesn't use the event system simply never calls
        // `nativeRegisterObserverHooks`. ExceptionCheck() prevents
        // a sticky exception from poisoning later JNI calls.
        if (env->ExceptionCheck()) env->ExceptionClear();
    }
    h.ready = true;
    return true;
}

struct ScopedJNIEnv_M {
    JNIEnv* env = nullptr;
    bool attached = false;
    ScopedJNIEnv_M() {
        JavaVM* jvm = Handles().jvm;
        if (jvm == nullptr) return;
        int rc = jvm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6);
        if (rc == JNI_EDETACHED) {
            if (jvm->AttachCurrentThread(&env, nullptr) == JNI_OK) {
                attached = true;
            } else {
                env = nullptr;
            }
        }
    }
    ~ScopedJNIEnv_M() {
        if (attached && env != nullptr) {
            JavaVM* jvm = Handles().jvm;
            if (jvm != nullptr) jvm->DetachCurrentThread();
        }
    }
    JNIEnv* get() { return env; }
};

jobject value_to_jvalue(JNIEnv* env, const WhiskerValueRaw* v);
WhiskerValueRaw jvalue_to_value(JNIEnv* env, jobject obj);

jobject value_to_jvalue(JNIEnv* env, const WhiskerValueRaw* v) {
    auto& h = wvjni();
    if (v == nullptr) return env->NewLocalRef(h.null_obj);
    switch (v->type) {
        case WHISKER_VALUE_NULL:
            return env->NewLocalRef(h.null_obj);
        case WHISKER_VALUE_BOOL:
            return env->NewObject(h.bool_cls, h.bool_ctor, (jboolean)v->v.b);
        case WHISKER_VALUE_INT:
            return env->NewObject(h.int_cls, h.int_ctor, (jlong)v->v.i);
        case WHISKER_VALUE_FLOAT:
            return env->NewObject(h.float_cls, h.float_ctor, (jdouble)v->v.f);
        case WHISKER_VALUE_STRING: {
            std::string s = (v->v.s.ptr != nullptr)
                ? std::string(v->v.s.ptr, v->v.s.len) : std::string();
            jstring js = env->NewStringUTF(s.c_str());
            jobject out = env->NewObject(h.str_cls, h.str_ctor, js);
            env->DeleteLocalRef(js);
            return out;
        }
        case WHISKER_VALUE_BYTES: {
            jbyteArray arr = env->NewByteArray((jsize)v->v.bytes.len);
            if (v->v.bytes.len > 0 && v->v.bytes.ptr != nullptr) {
                env->SetByteArrayRegion(arr, 0, (jsize)v->v.bytes.len,
                    reinterpret_cast<const jbyte*>(v->v.bytes.ptr));
            }
            jobject out = env->NewObject(h.bytes_cls, h.bytes_ctor, arr);
            env->DeleteLocalRef(arr);
            return out;
        }
        case WHISKER_VALUE_ARRAY: {
            jobject list = env->NewObject(h.arraylist_cls, h.arraylist_ctor);
            for (size_t i = 0; i < v->v.array.count; i++) {
                jobject item = value_to_jvalue(env, &v->v.array.items[i]);
                env->CallBooleanMethod(list, h.arraylist_add, item);
                if (item != nullptr) env->DeleteLocalRef(item);
            }
            jobject out = env->NewObject(h.array_cls, h.array_ctor, list);
            env->DeleteLocalRef(list);
            return out;
        }
        case WHISKER_VALUE_MAP: {
            jobject map = env->NewObject(h.hashmap_cls, h.hashmap_ctor);
            for (size_t i = 0; i < v->v.map.count; i++) {
                std::string k(v->v.map.entries[i].key.ptr,
                              v->v.map.entries[i].key.len);
                jstring kj = env->NewStringUTF(k.c_str());
                jobject vj = value_to_jvalue(env, &v->v.map.entries[i].value);
                jobject prev = env->CallObjectMethod(map, h.hashmap_put, kj, vj);
                if (prev != nullptr) env->DeleteLocalRef(prev);
                env->DeleteLocalRef(kj);
                if (vj != nullptr) env->DeleteLocalRef(vj);
            }
            jobject out = env->NewObject(h.map_cls, h.map_ctor, map);
            env->DeleteLocalRef(map);
            return out;
        }
        case WHISKER_VALUE_ERROR: {
            std::string s = (v->v.s.ptr != nullptr)
                ? std::string(v->v.s.ptr, v->v.s.len) : std::string();
            jstring js = env->NewStringUTF(s.c_str());
            jobject out = env->NewObject(h.err_cls, h.err_ctor, js);
            env->DeleteLocalRef(js);
            return out;
        }
        default:
            return env->NewLocalRef(h.null_obj);
    }
}

std::string jstr_to_str(JNIEnv* env, jstring js) {
    if (js == nullptr) return std::string();
    const char* p = env->GetStringUTFChars(js, nullptr);
    std::string s = p != nullptr ? std::string(p) : std::string();
    if (p != nullptr) env->ReleaseStringUTFChars(js, p);
    return s;
}

char* dup_malloc(const std::string& s) {
    char* buf = static_cast<char*>(std::malloc(s.size() + 1));
    std::memcpy(buf, s.c_str(), s.size() + 1);
    return buf;
}

WhiskerValueRaw jvalue_to_value(JNIEnv* env, jobject obj) {
    WhiskerValueRaw v;
    std::memset(&v, 0, sizeof(v));
    auto& h = wvjni();
    if (obj == nullptr) { v.type = WHISKER_VALUE_NULL; return v; }
    if (env->IsInstanceOf(obj, h.null_cls))  { v.type = WHISKER_VALUE_NULL; return v; }
    if (env->IsInstanceOf(obj, h.bool_cls))  { v.type = WHISKER_VALUE_BOOL;
        v.v.b = env->CallBooleanMethod(obj, h.bool_get); return v; }
    if (env->IsInstanceOf(obj, h.int_cls))   { v.type = WHISKER_VALUE_INT;
        v.v.i = env->CallLongMethod(obj, h.int_get); return v; }
    if (env->IsInstanceOf(obj, h.float_cls)) { v.type = WHISKER_VALUE_FLOAT;
        v.v.f = env->CallDoubleMethod(obj, h.float_get); return v; }
    if (env->IsInstanceOf(obj, h.str_cls)) {
        jstring js = (jstring)env->CallObjectMethod(obj, h.str_get);
        std::string s = jstr_to_str(env, js);
        if (js != nullptr) env->DeleteLocalRef(js);
        v.type = WHISKER_VALUE_STRING;
        v.v.s.ptr = dup_malloc(s); v.v.s.len = s.size(); return v;
    }
    if (env->IsInstanceOf(obj, h.bytes_cls)) {
        jbyteArray arr = (jbyteArray)env->CallObjectMethod(obj, h.bytes_get);
        jsize len = (arr != nullptr) ? env->GetArrayLength(arr) : 0;
        uint8_t* buf = static_cast<uint8_t*>(std::malloc(len > 0 ? (size_t)len : 1));
        if (len > 0 && arr != nullptr) {
            env->GetByteArrayRegion(arr, 0, len, reinterpret_cast<jbyte*>(buf));
        }
        if (arr != nullptr) env->DeleteLocalRef(arr);
        v.type = WHISKER_VALUE_BYTES;
        v.v.bytes.ptr = buf; v.v.bytes.len = (size_t)len; return v;
    }
    if (env->IsInstanceOf(obj, h.array_cls)) {
        jobject list = env->CallObjectMethod(obj, h.array_get);
        jint sz = env->CallIntMethod(list, h.list_size);
        WhiskerValueRaw* items = static_cast<WhiskerValueRaw*>(
            std::malloc(((size_t)sz > 0 ? (size_t)sz : 1) * sizeof(WhiskerValueRaw)));
        for (jint i = 0; i < sz; i++) {
            jobject elem = env->CallObjectMethod(list, h.list_get, i);
            items[i] = jvalue_to_value(env, elem);
            if (elem != nullptr) env->DeleteLocalRef(elem);
        }
        env->DeleteLocalRef(list);
        v.type = WHISKER_VALUE_ARRAY;
        v.v.array.items = items; v.v.array.count = (size_t)sz; return v;
    }
    if (env->IsInstanceOf(obj, h.map_cls)) {
        jobject map = env->CallObjectMethod(obj, h.map_get);
        jobject set = env->CallObjectMethod(map, h.map_entry_set);
        jobject it  = env->CallObjectMethod(set, h.set_iterator);
        std::vector<std::pair<std::string, WhiskerValueRaw>> tmp;
        while (env->CallBooleanMethod(it, h.iter_has_next)) {
            jobject entry = env->CallObjectMethod(it, h.iter_next);
            jstring ks = (jstring)env->CallObjectMethod(entry, h.map_entry_key);
            jobject vo = env->CallObjectMethod(entry, h.map_entry_val);
            std::string k = jstr_to_str(env, ks);
            WhiskerValueRaw val = jvalue_to_value(env, vo);
            tmp.emplace_back(std::move(k), val);
            if (ks != nullptr) env->DeleteLocalRef(ks);
            if (vo != nullptr) env->DeleteLocalRef(vo);
            env->DeleteLocalRef(entry);
        }
        env->DeleteLocalRef(it); env->DeleteLocalRef(set); env->DeleteLocalRef(map);
        size_t n = tmp.size();
        WhiskerKeyValueRaw* entries = static_cast<WhiskerKeyValueRaw*>(
            std::malloc((n > 0 ? n : 1) * sizeof(WhiskerKeyValueRaw)));
        for (size_t i = 0; i < n; i++) {
            entries[i].key.ptr = dup_malloc(tmp[i].first);
            entries[i].key.len = tmp[i].first.size();
            entries[i].value = tmp[i].second;
        }
        v.type = WHISKER_VALUE_MAP;
        v.v.map.entries = entries; v.v.map.count = n; return v;
    }
    if (env->IsInstanceOf(obj, h.err_cls)) {
        jstring js = (jstring)env->CallObjectMethod(obj, h.err_get_msg);
        std::string s = jstr_to_str(env, js);
        if (js != nullptr) env->DeleteLocalRef(js);
        v.type = WHISKER_VALUE_ERROR;
        v.v.s.ptr = dup_malloc(s); v.v.s.len = s.size(); return v;
    }
    v.type = WHISKER_VALUE_ERROR;
    const char* msg = "unknown WhiskerValueRaw subtype";
    v.v.s.ptr = dup_malloc(msg); v.v.s.len = std::strlen(msg); return v;
}

// Marshal a **raw Java** event-body object (the `Map<String, Any?>`
// Lynx hands `LynxCustomEvent.eventParams()`, with Boolean / Number /
// String / nested Map / List values) into a `WhiskerValueRaw` tree.
// Distinct from `jvalue_to_value` (which expects Kotlin `WhiskerValue`
// sealed-class instances) — the event reporter only has plain boxed
// Java types. Unknown types degrade to `WHISKER_VALUE_NULL`.
WhiskerValueRaw jobject_to_value(JNIEnv* env, jobject obj) {
    WhiskerValueRaw v;
    std::memset(&v, 0, sizeof(v));
    auto& h = wvjni();
    if (obj == nullptr) { v.type = WHISKER_VALUE_NULL; return v; }
    if (h.jbool_cls != nullptr && env->IsInstanceOf(obj, h.jbool_cls)) {
        v.type = WHISKER_VALUE_BOOL;
        v.v.b = env->CallBooleanMethod(obj, h.jbool_value);
        return v;
    }
    if ((h.jdouble_cls != nullptr && env->IsInstanceOf(obj, h.jdouble_cls)) ||
        (h.jfloat_cls != nullptr && env->IsInstanceOf(obj, h.jfloat_cls))) {
        v.type = WHISKER_VALUE_FLOAT;
        v.v.f = env->CallDoubleMethod(obj, h.jnumber_double);
        return v;
    }
    if (h.jnumber_cls != nullptr && env->IsInstanceOf(obj, h.jnumber_cls)) {
        v.type = WHISKER_VALUE_INT;
        v.v.i = env->CallLongMethod(obj, h.jnumber_long);
        return v;
    }
    if (h.jstring_cls != nullptr && env->IsInstanceOf(obj, h.jstring_cls)) {
        std::string s = jstr_to_str(env, static_cast<jstring>(obj));
        v.type = WHISKER_VALUE_STRING;
        v.v.s.ptr = dup_malloc(s); v.v.s.len = s.size();
        return v;
    }
    if (env->IsInstanceOf(obj, h.list_cls)) {
        jint sz = env->CallIntMethod(obj, h.list_size);
        WhiskerValueRaw* items = static_cast<WhiskerValueRaw*>(
            std::malloc(((size_t)sz > 0 ? (size_t)sz : 1) * sizeof(WhiskerValueRaw)));
        for (jint i = 0; i < sz; i++) {
            jobject elem = env->CallObjectMethod(obj, h.list_get, i);
            items[i] = jobject_to_value(env, elem);
            if (elem != nullptr) env->DeleteLocalRef(elem);
        }
        v.type = WHISKER_VALUE_ARRAY;
        v.v.array.items = items; v.v.array.count = (size_t)sz;
        return v;
    }
    if (env->IsInstanceOf(obj, h.map_iface)) {
        jobject set = env->CallObjectMethod(obj, h.map_entry_set);
        jobject it  = env->CallObjectMethod(set, h.set_iterator);
        std::vector<std::pair<std::string, WhiskerValueRaw>> tmp;
        while (env->CallBooleanMethod(it, h.iter_has_next)) {
            jobject entry = env->CallObjectMethod(it, h.iter_next);
            jobject ko = env->CallObjectMethod(entry, h.map_entry_key);
            jobject vo = env->CallObjectMethod(entry, h.map_entry_val);
            std::string k = (ko != nullptr && env->IsInstanceOf(ko, h.jstring_cls))
                ? jstr_to_str(env, static_cast<jstring>(ko))
                : std::string();
            WhiskerValueRaw val = jobject_to_value(env, vo);
            tmp.emplace_back(std::move(k), val);
            if (ko != nullptr) env->DeleteLocalRef(ko);
            if (vo != nullptr) env->DeleteLocalRef(vo);
            env->DeleteLocalRef(entry);
        }
        env->DeleteLocalRef(it); env->DeleteLocalRef(set);
        size_t n = tmp.size();
        WhiskerKeyValueRaw* entries = static_cast<WhiskerKeyValueRaw*>(
            std::malloc((n > 0 ? n : 1) * sizeof(WhiskerKeyValueRaw)));
        for (size_t i = 0; i < n; i++) {
            entries[i].key.ptr = dup_malloc(tmp[i].first);
            entries[i].key.len = tmp[i].first.size();
            entries[i].value = tmp[i].second;
        }
        v.type = WHISKER_VALUE_MAP;
        v.v.map.entries = entries; v.v.map.count = n;
        return v;
    }
    v.type = WHISKER_VALUE_NULL;
    return v;
}

}  // namespace

// Called from Kotlin's `EventEmitter.LynxEventReporter` for every
// LynxEvent the engine fires. `params` is the event body as a raw
// Java `Map<String, Any?>` (or null for bodyless events like touch);
// we marshal it into a `WhiskerValueRaw` tree, dispatch to the
// registered Rust callback, then release the tree. Returns true if a
// callback was found and fired.
extern "C" JNIEXPORT jboolean JNICALL
Java_rs_whisker_runtime_WhiskerView_nativeOnLynxEvent(
    JNIEnv* env, jobject /*self*/, jlong /*engine_raw*/,
    jint tag, jstring name_jstr, jobject params) {
    if (name_jstr == nullptr) return JNI_FALSE;
    const char* name = env->GetStringUTFChars(name_jstr, nullptr);
    if (name == nullptr) return JNI_FALSE;

    bool handled = false;
    if (params == nullptr) {
        // Bodyless event (touch / focus / …) — dispatch with no value.
        handled = whisker_bridge_internal_dispatch_event(
            static_cast<int32_t>(tag), name, nullptr);
    } else if (init_wvjni(env)) {
        WhiskerValueRaw value = jobject_to_value(env, params);
        handled = whisker_bridge_internal_dispatch_event(
            static_cast<int32_t>(tag), name, &value);
        whisker_bridge_value_release(&value);
    } else {
        // JNI handles failed to init — fall back to bodyless dispatch
        // so the handler still fires.
        if (env->ExceptionCheck()) env->ExceptionClear();
        handled = whisker_bridge_internal_dispatch_event(
            static_cast<int32_t>(tag), name, nullptr);
    }

    env->ReleaseStringUTFChars(name_jstr, name);
    return handled ? JNI_TRUE : JNI_FALSE;
}

extern "C" WhiskerValueRaw whisker_bridge_invoke_module(
    const char* module_name, const char* method_name,
    const WhiskerValueRaw* args, size_t arg_count
) {
    if (module_name == nullptr || method_name == nullptr) {
        return MakeAndroidBridgeError("module/method name NULL");
    }
    ScopedJNIEnv_M guard;
    JNIEnv* env = guard.get();
    if (env == nullptr) {
        return MakeAndroidBridgeError("JVM not initialised");
    }
    if (!init_wvjni(env)) {
        return MakeAndroidBridgeError("WhiskerValueRaw JNI init failed");
    }
    auto& h = wvjni();
    jobjectArray jargs = env->NewObjectArray((jsize)arg_count, h.base, nullptr);
    for (size_t i = 0; i < arg_count; i++) {
        jobject jv = value_to_jvalue(env, &args[i]);
        env->SetObjectArrayElement(jargs, (jsize)i, jv);
        if (jv != nullptr) env->DeleteLocalRef(jv);
    }
    jstring jmod = env->NewStringUTF(module_name);
    jstring jmtd = env->NewStringUTF(method_name);
    jobject jres = env->CallStaticObjectMethod(
        h.registry_cls, h.registry_dispatch, jmod, jmtd, jargs);
    env->DeleteLocalRef(jmod); env->DeleteLocalRef(jmtd); env->DeleteLocalRef(jargs);
    if (env->ExceptionCheck()) { env->ExceptionDescribe(); env->ExceptionClear();
        return MakeAndroidBridgeError("dispatch threw"); }
    WhiskerValueRaw out = jvalue_to_value(env, jres);
    if (jres != nullptr) env->DeleteLocalRef(jres);
    return out;
}

extern "C" bool whisker_bridge_invoke_module_async(
    const char* module_name, const char* method_name,
    const WhiskerValueRaw* args, size_t arg_count,
    WhiskerModuleCallback callback, void* user_data
) {
    if (callback == nullptr) return false;
    WhiskerValueRaw r = whisker_bridge_invoke_module(module_name, method_name, args, arg_count);
    callback(user_data, &r);
    whisker_bridge_value_release(&r);
    return true;
}

// ============================================================================
// Phase L-2c — module event subscription, Android side.
// ============================================================================

namespace {

// Shared C trampolines registered with the bridge once per Module
// via `whisker_bridge_module_register_observer_hooks`. C function
// pointers can't carry per-module context, so we route the
// `(module, event)` pair through Kotlin's `WhiskerModuleEventCenter`
// which uses `module` to find the right `Module` instance and fires
// every matching `OnStartObserving` / `OnStopObserving` closure.
void AndroidEventStartHook(const char* module_name, const char* event_name) {
    if (module_name == nullptr || event_name == nullptr) return;
    auto& h = wvjni();
    if (h.event_center_cls == nullptr || h.event_center_fire_start == nullptr) {
        return;
    }
    ScopedJNIEnv_M guard;
    JNIEnv* env = guard.get();
    if (env == nullptr) return;
    jstring jmod = env->NewStringUTF(module_name);
    jstring jevt = env->NewStringUTF(event_name);
    env->CallStaticVoidMethod(
        h.event_center_cls, h.event_center_fire_start, jmod, jevt);
    if (env->ExceptionCheck()) {
        env->ExceptionDescribe();
        env->ExceptionClear();
    }
    env->DeleteLocalRef(jmod);
    env->DeleteLocalRef(jevt);
}

void AndroidEventStopHook(const char* module_name, const char* event_name) {
    if (module_name == nullptr || event_name == nullptr) return;
    auto& h = wvjni();
    if (h.event_center_cls == nullptr || h.event_center_fire_stop == nullptr) {
        return;
    }
    ScopedJNIEnv_M guard;
    JNIEnv* env = guard.get();
    if (env == nullptr) return;
    jstring jmod = env->NewStringUTF(module_name);
    jstring jevt = env->NewStringUTF(event_name);
    env->CallStaticVoidMethod(
        h.event_center_cls, h.event_center_fire_stop, jmod, jevt);
    if (env->ExceptionCheck()) {
        env->ExceptionDescribe();
        env->ExceptionClear();
    }
    env->DeleteLocalRef(jmod);
    env->DeleteLocalRef(jevt);
}

}  // namespace

// rs.whisker.runtime.WhiskerModuleEventCenter.nativeRegisterObserverHooks
extern "C" JNIEXPORT void JNICALL
Java_rs_whisker_runtime_WhiskerModuleEventCenter_nativeRegisterObserverHooks(
    JNIEnv* env, jclass /*self*/, jstring qname_jstr) {
    if (qname_jstr == nullptr) return;
    if (!init_wvjni(env)) return;
    std::string qname = jstr_to_str(env, qname_jstr);
    whisker_bridge_module_register_observer_hooks(
        qname.c_str(),
        AndroidEventStartHook,
        AndroidEventStopHook);
}

// rs.whisker.runtime.WhiskerModuleEventCenter.nativeSendEvent
extern "C" JNIEXPORT void JNICALL
Java_rs_whisker_runtime_WhiskerModuleEventCenter_nativeSendEvent(
    JNIEnv* env, jclass /*self*/,
    jstring qname_jstr, jstring event_jstr, jobject payload_obj) {
    if (qname_jstr == nullptr || event_jstr == nullptr) return;
    if (!init_wvjni(env)) return;
    std::string qname = jstr_to_str(env, qname_jstr);
    std::string event = jstr_to_str(env, event_jstr);
    // Encode the WhiskerValue jobject into a WhiskerValueRaw whose
    // strings / bytes / nested arrays / maps are heap-owned. The
    // bridge fans the payload out synchronously, so we can release
    // the heap allocations immediately after.
    WhiskerValueRaw raw = jvalue_to_value(env, payload_obj);
    whisker_bridge_module_send_event(qname.c_str(), event.c_str(), &raw);
    whisker_bridge_value_release(&raw);
}

#endif  // __ANDROID__