@fireflydb/expo-driver 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,253 @@
1
+ # @fireflydb/expo
2
+
3
+ Expo native module that bundles the Rust `libfirefly` SQLite extension and
4
+ wires it into [expo-sqlite](https://docs.expo.dev/versions/latest/sdk/sqlite/),
5
+ [expo-secure-store](https://docs.expo.dev/versions/latest/sdk/securestore/),
6
+ and React Native WebSocket. Exposes a change-listener bridge so JS gets
7
+ notified of CRDT-relevant local writes.
8
+
9
+ This document focuses on **how the native core is built and shipped**. For
10
+ SDK usage, see [`packages/core`](../core).
11
+
12
+ ---
13
+
14
+ ## Layout
15
+
16
+ ```
17
+ packages/expo/
18
+ ├── cpp/ # Cross-platform C++ shared by iOS + Android
19
+ │ ├── firefly_abi.h # Rust C-ABI declarations (firefly_add_change_listener, …)
20
+ │ ├── firefly_listener.h # Listener registry public surface
21
+ │ └── firefly_listener.cpp # Process-wide handle→dispatch registry
22
+
23
+ ├── ios/
24
+ │ ├── Firefly.xcframework/ # ← produced by scripts/build-libfirefly.sh
25
+ │ ├── FireflyClient.podspec # Vends the xcframework + symlinks ../cpp into the pod
26
+ │ └── FireflyClientModule.swift
27
+
28
+ ├── android/
29
+ │ ├── build.gradle # Wires jniLibs + externalNativeBuild (CMake)
30
+ │ └── src/main/
31
+ │ ├── cpp/CMakeLists.txt # Imports prebuilt libfirefly.so, builds libfirefly_jni.so
32
+ │ ├── cpp/firefly_jni.cpp # JNI shim
33
+ │ └── jniLibs/<abi>/libfirefly.so # ← produced by scripts/build-libfirefly.sh
34
+
35
+ ├── scripts/
36
+ │ └── build-libfirefly.sh # Cross-compiles core/ for iOS + Android
37
+
38
+ └── src/ # TypeScript surface (drivers + module bindings)
39
+ ```
40
+
41
+ The single source of truth for the native core is the `core/` Rust crate at
42
+ the repo root. Everything under `ios/Firefly.xcframework/` and
43
+ `android/src/main/jniLibs/` is **build output** (gitignored) — never hand-edit it.
44
+
45
+ ---
46
+
47
+ ## Rebuilding the core
48
+
49
+ Run from this package directory:
50
+
51
+ ```bash
52
+ pnpm build:libfirefly # both platforms
53
+ bash scripts/build-libfirefly.sh ios # iOS only
54
+ bash scripts/build-libfirefly.sh android # Android only
55
+ ```
56
+
57
+ The script ([scripts/build-libfirefly.sh](scripts/build-libfirefly.sh))
58
+ cross-compiles `core/` with `--no-default-features --features
59
+ loadable_extension` so the resulting binary is a SQLite loadable extension
60
+ (no bundled SQLite — both Expo and Android ship their own).
61
+
62
+ ### Prerequisites
63
+
64
+ | Tool | Used for | Auto-installed by script? |
65
+ |------|----------|---------------------------|
66
+ | `rustup` + Rust toolchain | All targets | No (must be on PATH) |
67
+ | iOS rust targets (`aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios`) | iOS | Yes (`rustup target add`) |
68
+ | Android rust targets (`aarch64-linux-android`, `armv7-linux-androideabi`, `x86_64-linux-android`, `i686-linux-android`) | Android | Yes |
69
+ | `xcodebuild`, `lipo`, `install_name_tool` | iOS framework assembly | No (Xcode CLT) |
70
+ | `cargo-ndk` | Android cross-compile | Yes (`cargo install`) |
71
+ | Android NDK r24+ (`ANDROID_NDK_HOME` exported) | Android | No |
72
+
73
+ ### When to rebuild
74
+
75
+ Rebuild after **any change under `core/`** that affects the C-ABI surface
76
+ or runtime behavior. The `.xcframework` and `.so` files are gitignored build
77
+ output: the `prepack` hook rebuilds them on `pnpm pack` / `pnpm publish`, so
78
+ the published package ships binaries and consumers never need a Rust
79
+ toolchain. In a fresh clone, run `pnpm build:libfirefly` once before building
80
+ the example app.
81
+
82
+ The shared C++ in `cpp/` (`firefly_listener.{cpp,h}`) is **not** built by
83
+ this script — it's compiled by the platform build systems (CocoaPods on
84
+ iOS, Gradle/CMake on Android) when the host app builds. Editing it requires
85
+ no rebuild script run; just rebuild the host app.
86
+
87
+ ---
88
+
89
+ ## iOS pipeline
90
+
91
+ ```
92
+ core/ ──cargo build──▶ target/<rust-target>/release/libfirefly.dylib
93
+
94
+ ├─ aarch64-apple-ios ─┐
95
+ ├─ aarch64-apple-ios-sim ─┼─lipo──▶ sim-universal
96
+ └─ x86_64-apple-ios ─┘
97
+
98
+ device-arm64.dylib sim-universal.dylib
99
+ │ │
100
+ ios_make_framework ios_make_framework
101
+ ▼ ▼
102
+ Firefly.framework (device) Firefly.framework (sim-universal)
103
+ └──── xcodebuild -create-xcframework ────┘
104
+
105
+ ios/Firefly.xcframework/
106
+ ```
107
+
108
+ What the script does, in order:
109
+
110
+ 1. **Per-slice `cargo build`** for each iOS target into the workspace
111
+ `target/` dir (the workspace lives at the repo root, not under `core/`).
112
+ 2. **`lipo`** the two simulator slices (`arm64-sim` + `x86_64`) into a
113
+ single fat dylib. iOS device stays as a single `arm64` slice.
114
+ 3. **`ios_make_framework`** wraps each dylib in a proper
115
+ `Firefly.framework` bundle: copies the dylib in as the bundle binary
116
+ (named `Firefly`, no extension), rewrites its `LC_ID_DYLIB` install
117
+ name to `@rpath/Firefly.framework/Firefly` via `install_name_tool`, and
118
+ writes an `Info.plist` with the right `CFBundleSupportedPlatforms` /
119
+ `MinimumOSVersion`.
120
+ 4. **`xcodebuild -create-xcframework`** combines the device and
121
+ simulator-universal frameworks into `Firefly.xcframework`. This is the
122
+ canonical Apple distribution shape and the only one CocoaPods accepts
123
+ via `s.vendored_frameworks` for **dynamic** linking.
124
+ 5. **`nm` verification** that `_sqlite3_firefly_init` is exported in both
125
+ slices — that's the entry point `expo-sqlite` calls via
126
+ `sqlite3_load_extension`.
127
+
128
+ `FireflyClient.podspec` then:
129
+ - Vendors `Firefly.xcframework` (Xcode embeds the right slice into
130
+ `<App>.app/Frameworks/Firefly.framework/Firefly` and re-signs at archive
131
+ time).
132
+ - Pulls in the shared C++ via `ios/_shared/*.{cpp,h}` (a committed
133
+ directory of per-file symlinks pointing at `../cpp/*`). This avoids
134
+ a separate `.mm` shim — Swift binds to the C-ABI symbols via
135
+ `@_silgen_name`. See the long comment in
136
+ [`FireflyClient.podspec`](ios/FireflyClient.podspec) for why
137
+ `_shared/` is committed instead of generated by `prepare_command`
138
+ (CocoaPods skips `prepare_command` for `:path =>` pods).
139
+
140
+ ---
141
+
142
+ ## Android pipeline
143
+
144
+ ```
145
+ core/ ──cargo ndk build──▶ target/<rust-target>/release/libfirefly.so
146
+
147
+ ├─ aarch64-linux-android ─▶ jniLibs/arm64-v8a/libfirefly.so
148
+ ├─ armv7-linux-androideabi ─▶ jniLibs/armeabi-v7a/libfirefly.so
149
+ ├─ x86_64-linux-android ─▶ jniLibs/x86_64/libfirefly.so
150
+ └─ i686-linux-android ─▶ jniLibs/x86/libfirefly.so
151
+
152
+ (later, at app build time:)
153
+
154
+ android/src/main/cpp/firefly_jni.cpp + cpp/firefly_listener.cpp
155
+
156
+ CMake (externalNativeBuild)
157
+
158
+ jniLibs/<abi>/libfirefly_jni.so (per ABI, links against libfirefly.so)
159
+ ```
160
+
161
+ What the script does:
162
+
163
+ 1. **`cargo ndk --target <rust-target> --platform 24`** for each ABI. The
164
+ `--platform 24` matches `minSdkVersion 24` in `android/build.gradle`.
165
+ `cargo-ndk` handles wiring the NDK toolchain into Cargo so the Rust
166
+ compiler emits ABI-correct shared objects.
167
+ 2. **Copies** each `libfirefly.so` into
168
+ `android/src/main/jniLibs/<abi>/`.
169
+
170
+ At app build time, Gradle (`android/build.gradle`):
171
+ - Picks up the prebuilt `libfirefly.so` from `jniLibs/<abi>/` and packages
172
+ them into the APK/AAB — `applicationInfo.nativeLibraryDir` resolves them
173
+ at runtime.
174
+ - Compiles `libfirefly_jni.so` per ABI via `externalNativeBuild` /
175
+ CMakeLists ([android/src/main/cpp/CMakeLists.txt](android/src/main/cpp/CMakeLists.txt)).
176
+ CMake imports `libfirefly.so` as an `IMPORTED` target and links the JNI
177
+ shim against it plus the shared `cpp/firefly_listener.cpp`.
178
+ - `packagingOptions { doNotStrip "**/libfirefly.so" }` — the Rust release
179
+ profile already strips, and stripping again can drop the
180
+ `sqlite3_firefly_init` export.
181
+
182
+ Kotlin loads the native library with `System.loadLibrary("firefly_jni")`,
183
+ which transitively pulls in `libfirefly.so` because of the import
184
+ relationship in CMake.
185
+
186
+ ---
187
+
188
+ ## Shared C++ (`cpp/`)
189
+
190
+ Both platforms include `cpp/firefly_listener.cpp` directly in their build
191
+ ([`firefly_listener.cpp`](cpp/firefly_listener.cpp)). It's a single
192
+ process-wide registry mapping a caller-supplied `int64_t handle` to a
193
+ platform dispatcher (Swift `sendEvent` on iOS, JNI `CallStaticVoidMethod`
194
+ on Android). Keeping this in one place means the lifetime/race semantics
195
+ required by `firefly_add_change_listener`'s contract are implemented
196
+ once, not twice.
197
+
198
+ Linking model:
199
+ - **iOS:** compiled into the `FireflyClient` pod alongside the Swift
200
+ module, with `-fvisibility=hidden`. Swift uses `@_silgen_name` to bind
201
+ to `firefly_listener_register` / `firefly_listener_unregister`.
202
+ - **Android:** compiled into `libfirefly_jni.so` by CMake. JNI calls into
203
+ it directly.
204
+
205
+ The C-ABI between this layer and Rust (`firefly_add_change_listener`,
206
+ `firefly_remove_change_listener`) is declared in
207
+ [`cpp/firefly_abi.h`](cpp/firefly_abi.h).
208
+
209
+ ---
210
+
211
+ ## Troubleshooting
212
+
213
+ **`sqlite3_firefly_init` not exported.** The script verifies this on iOS
214
+ and aborts the build. If it ever fires, check that `core/src/lib.rs` still
215
+ declares the entry point with the `sqlite_extension_init` macro and that
216
+ release-profile `strip = true` isn't dropping the symbol on a new
217
+ toolchain.
218
+
219
+ **Android NDK not found.** Export `ANDROID_NDK_HOME` (e.g.
220
+ `export ANDROID_NDK_HOME=$ANDROID_HOME/ndk/27.1.12297006`). `cargo-ndk`
221
+ reads it.
222
+
223
+ **`linker 'cc' not found` on Android targets.** `cargo-ndk` injects the
224
+ right linker via env vars only inside its child `cargo` invocation. If you
225
+ ran `cargo build --target aarch64-linux-android` directly (not via
226
+ `cargo ndk`), you'll get this. Use the script.
227
+
228
+ **Stale `target/` cache after toolchain bump.** `cargo clean` at the repo
229
+ root, then re-run the script. The targets are cross builds so they don't
230
+ share intermediate artifacts with desktop dev builds, but a corrupt cache
231
+ across a Rust version bump can manifest as missing symbols.
232
+
233
+ **`Undefined symbol: _firefly_listener_register` at iOS link time.** The
234
+ shared C++ in `cpp/` is pulled into the pod via the committed
235
+ `ios/_shared/` directory of per-file symlinks. Two CocoaPods quirks
236
+ combined to cause earlier breakage here:
237
+ 1. CocoaPods indexes pod sources via Ruby's `Find.find`, which does NOT
238
+ recurse into symlinked directories — so `_shared` cannot itself be
239
+ a symlink. It must be a real directory containing file-level
240
+ symlinks (which Find DOES list).
241
+ 2. CocoaPods does NOT run `prepare_command` for development
242
+ (`:path =>`) pods. Anything that expects to be set up at
243
+ `pod install` time silently no-ops.
244
+
245
+ So `_shared/` is checked into git; if you add a file under `cpp/`,
246
+ mirror it with `ln -sfn ../../cpp/<name> ios/_shared/<name>` and commit.
247
+ After podspec or `_shared/` changes, re-run `pod install` (or
248
+ `npx expo prebuild --clean` for Expo apps) so the Pods xcodeproj
249
+ regenerates with the new sources. The `cannot link directly with
250
+ 'SwiftUICore'` line that often appears in the Expo CLI output is
251
+ unrelated noise from Xcode 26 — check
252
+ `apps/<app>/.expo/xcodebuild.log` for the real `Undefined symbols`
253
+ section.
@@ -0,0 +1,51 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'org.jetbrains.kotlin.android'
4
+ id 'expo-module-gradle-plugin'
5
+ }
6
+
7
+ group = 'com.fireflydb.expo'
8
+ version = '0.0.0'
9
+
10
+ android {
11
+ namespace "com.fireflydb.expo"
12
+
13
+ defaultConfig {
14
+ minSdkVersion 24
15
+ compileSdk 34
16
+ versionCode 1
17
+ versionName "0.0.0"
18
+
19
+ externalNativeBuild {
20
+ cmake {
21
+ cppFlags '-std=c++17', '-fvisibility=hidden'
22
+ }
23
+ }
24
+ }
25
+
26
+ externalNativeBuild {
27
+ cmake {
28
+ // Builds libfirefly_jni.so per ABI. CMakeLists.txt pulls in the
29
+ // pre-built libfirefly.so from src/main/jniLibs/${ANDROID_ABI}/.
30
+ path "src/main/cpp/CMakeLists.txt"
31
+ version "3.22.1"
32
+ }
33
+ }
34
+
35
+ sourceSets {
36
+ main {
37
+ // jniLibs/<abi>/libfirefly.so is produced by scripts/build-libfirefly.sh.
38
+ // Gradle picks them up here and packages them into the host APK/AAB
39
+ // so applicationInfo.nativeLibraryDir resolves them at runtime.
40
+ // libfirefly_jni.so is produced by the externalNativeBuild above
41
+ // and packaged automatically.
42
+ jniLibs.srcDirs += ['src/main/jniLibs']
43
+ }
44
+ }
45
+
46
+ packagingOptions {
47
+ // Don't strip libfirefly.so. The Rust release profile already strips
48
+ // symbols for size; we keep the SQLite extension entry point exported.
49
+ doNotStrip "**/libfirefly.so"
50
+ }
51
+ }
@@ -0,0 +1,2 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,21 @@
1
+ cmake_minimum_required(VERSION 3.22.1)
2
+ project(firefly_jni)
3
+
4
+ # Pre-built libfirefly.so per ABI is already in jniLibs/${ANDROID_ABI}/.
5
+ # Import it so the JNI shim can resolve the firefly_* C-ABI symbols at
6
+ # load time, then `System.loadLibrary("firefly_jni")` in Kotlin pulls
7
+ # both shared objects into the process.
8
+ add_library(firefly SHARED IMPORTED)
9
+ set_target_properties(firefly PROPERTIES IMPORTED_LOCATION
10
+ ${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libfirefly.so)
11
+
12
+ # Cross-platform listener registry shared with the iOS module. Lives at
13
+ # packages/expo/cpp/ — sibling of android/ and ios/. The path here
14
+ # climbs out of android/src/main/cpp/ to the package root.
15
+ set(SHARED_CPP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../../cpp)
16
+
17
+ add_library(firefly_jni SHARED
18
+ firefly_jni.cpp
19
+ ${SHARED_CPP_DIR}/firefly_listener.cpp)
20
+ target_include_directories(firefly_jni PRIVATE ${SHARED_CPP_DIR})
21
+ target_link_libraries(firefly_jni firefly log)
@@ -0,0 +1,110 @@
1
+ // JNI shim that bridges the cross-platform listener registry
2
+ // (`firefly_listener.{h,cpp}` at packages/expo/cpp/) to the JVM. The
3
+ // shared registry owns the libfirefly C-ABI lifetime and the
4
+ // integer-handle dispatch table; this file only marshals bytes between
5
+ // C++ and Kotlin's `FireflyJniBridge.dispatch(handle, byte[])`.
6
+
7
+ #include <jni.h>
8
+ #include <android/log.h>
9
+ #include <cstdint>
10
+
11
+ #include "firefly_listener.h"
12
+
13
+ #define LOG_TAG "FireflyJNI"
14
+ #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
15
+
16
+ namespace {
17
+
18
+ JavaVM* g_vm = nullptr;
19
+ jclass g_bridgeClass = nullptr; // global ref to FireflyJniBridge
20
+ jmethodID g_dispatchMethod = nullptr; // dispatch(long, byte[])
21
+
22
+ JNIEnv* getEnvOrAttach(bool* attached) {
23
+ *attached = false;
24
+ if (g_vm == nullptr) return nullptr;
25
+ JNIEnv* env = nullptr;
26
+ jint res = g_vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6);
27
+ if (res == JNI_OK) return env;
28
+ if (res == JNI_EDETACHED) {
29
+ if (g_vm->AttachCurrentThread(&env, nullptr) == JNI_OK) {
30
+ *attached = true;
31
+ return env;
32
+ }
33
+ }
34
+ return nullptr;
35
+ }
36
+
37
+ void detachIfNeeded(bool attached) {
38
+ if (attached && g_vm != nullptr) {
39
+ g_vm->DetachCurrentThread();
40
+ }
41
+ }
42
+
43
+ // Called by the shared C++ thunk on the SQLite writer thread for every
44
+ // fire that maps to a JNI-registered listener. Marshals the blob into
45
+ // a fresh `jbyteArray` and forwards via the Kotlin static dispatcher,
46
+ // which routes by handle and emits the Expo event.
47
+ void jniDispatch(int64_t handle, const uint8_t* blob, size_t len) {
48
+ if (g_vm == nullptr || g_bridgeClass == nullptr || g_dispatchMethod == nullptr) {
49
+ return;
50
+ }
51
+ bool attached = false;
52
+ JNIEnv* env = getEnvOrAttach(&attached);
53
+ if (env == nullptr) return;
54
+
55
+ jbyteArray arr = env->NewByteArray(static_cast<jsize>(len));
56
+ if (arr != nullptr) {
57
+ env->SetByteArrayRegion(
58
+ arr, 0, static_cast<jsize>(len),
59
+ reinterpret_cast<const jbyte*>(blob));
60
+ env->CallStaticVoidMethod(
61
+ g_bridgeClass, g_dispatchMethod, static_cast<jlong>(handle), arr);
62
+ if (env->ExceptionCheck()) {
63
+ env->ExceptionDescribe();
64
+ env->ExceptionClear();
65
+ }
66
+ env->DeleteLocalRef(arr);
67
+ }
68
+ detachIfNeeded(attached);
69
+ }
70
+
71
+ } // namespace
72
+
73
+ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* /*reserved*/) {
74
+ g_vm = vm;
75
+ JNIEnv* env = nullptr;
76
+ if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
77
+ return JNI_ERR;
78
+ }
79
+ jclass local = env->FindClass("com/fireflydb/expo/FireflyJniBridge");
80
+ if (local == nullptr) {
81
+ LOGE("FireflyJniBridge class not found");
82
+ return JNI_ERR;
83
+ }
84
+ g_bridgeClass = static_cast<jclass>(env->NewGlobalRef(local));
85
+ env->DeleteLocalRef(local);
86
+ g_dispatchMethod = env->GetStaticMethodID(g_bridgeClass, "dispatch", "(J[B)V");
87
+ if (g_dispatchMethod == nullptr) {
88
+ LOGE("FireflyJniBridge.dispatch(J[B)V not found");
89
+ return JNI_ERR;
90
+ }
91
+ return JNI_VERSION_1_6;
92
+ }
93
+
94
+ extern "C" JNIEXPORT jlong JNICALL
95
+ Java_com_fireflydb_expo_FireflyJniBridge_nativeRegister(
96
+ JNIEnv* env, jclass /*clazz*/, jstring path, jlong handle) {
97
+ if (path == nullptr) return 0;
98
+ const char* utf = env->GetStringUTFChars(path, nullptr);
99
+ if (utf == nullptr) return 0;
100
+ uint64_t rust_id = firefly_listener_register(
101
+ utf, static_cast<int64_t>(handle), &jniDispatch);
102
+ env->ReleaseStringUTFChars(path, utf);
103
+ return static_cast<jlong>(rust_id);
104
+ }
105
+
106
+ extern "C" JNIEXPORT void JNICALL
107
+ Java_com_fireflydb_expo_FireflyJniBridge_nativeUnregister(
108
+ JNIEnv* /*env*/, jclass /*clazz*/, jlong handle) {
109
+ firefly_listener_unregister(static_cast<int64_t>(handle));
110
+ }
@@ -0,0 +1,101 @@
1
+ package com.fireflydb.expo
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+ import java.io.File
6
+ import java.util.concurrent.ConcurrentHashMap
7
+ import java.util.concurrent.atomic.AtomicLong
8
+
9
+ class FireflyClientModule : Module() {
10
+ private val nextSwiftId = AtomicLong(1)
11
+ // Maps the JS-visible id to the JNI dispatch handle. The shared
12
+ // C++ registry tracks the libfirefly rust-id internally so we no
13
+ // longer keep it on the Kotlin side.
14
+ private val listeners = ConcurrentHashMap<Long, Long>()
15
+
16
+ override fun definition() = ModuleDefinition {
17
+ Name("FireflyClient")
18
+
19
+ Events("FireflyChange")
20
+
21
+ Function("getLibraryPath") {
22
+ // Resolve libfirefly.so against the app's native library dir.
23
+ // Returning an absolute path (rather than the bare name "firefly")
24
+ // sidesteps Android linker quirks: dlopen("firefly") on bionic does
25
+ // not auto-prepend "lib" or scan LD_LIBRARY_PATH the way glibc does.
26
+ //
27
+ // Strip the ".so" suffix before returning. SQLite's
28
+ // sqlite3_load_extension auto-appends the platform extension when
29
+ // the path lacks one; if we returned "/.../libfirefly.so" we'd
30
+ // end up with "libfirefly.so.so" and dlopen would fail.
31
+ val nativeLibDir = appContext.reactContext?.applicationInfo?.nativeLibraryDir
32
+ ?: throw IllegalStateException("FireflyClient: reactContext unavailable")
33
+
34
+ val absolute = File(nativeLibDir, "libfirefly.so")
35
+ if (!absolute.exists()) {
36
+ throw IllegalStateException(
37
+ "FireflyClient: libfirefly.so not found at ${absolute.absolutePath}. " +
38
+ "Did the host app rebuild after the @fireflydb/expo install?"
39
+ )
40
+ }
41
+ absolute.absolutePath.removeSuffix(".so")
42
+ }
43
+
44
+ Function("getEntryPoint") {
45
+ "sqlite3_firefly_init"
46
+ }
47
+
48
+ // Register a libfirefly change listener against the SQLite database
49
+ // file at `path`. The native side dispatches every encoded RowState
50
+ // batch to JS via the "FireflyChange" event. Returns the JS-visible
51
+ // listener id.
52
+ Function("addChangeListener") { path: String ->
53
+ val swiftId = nextSwiftId.getAndIncrement()
54
+
55
+ // Capture the swiftId so the dispatcher knows which JS-side
56
+ // listener to attribute the event to.
57
+ val jniHandle = FireflyJniBridge.register { blob ->
58
+ this@FireflyClientModule.sendEvent(
59
+ "FireflyChange",
60
+ mapOf(
61
+ "id" to swiftId.toDouble(),
62
+ "blob" to blob,
63
+ ),
64
+ )
65
+ }
66
+
67
+ val rustId = FireflyJniBridge.nativeRegister(path, jniHandle)
68
+ if (rustId == 0L) {
69
+ FireflyJniBridge.unregister(jniHandle)
70
+ throw IllegalStateException(
71
+ "firefly_listener_register rejected path '$path' " +
72
+ "(in-memory DB or invalid path?)"
73
+ )
74
+ }
75
+ listeners[swiftId] = jniHandle
76
+ // JS sees a Number; Long fits below 2^53 in any realistic session.
77
+ swiftId.toDouble()
78
+ }
79
+
80
+ Function("removeChangeListener") { handle: Double ->
81
+ val swiftId = handle.toLong()
82
+ val jniHandle = listeners.remove(swiftId) ?: return@Function
83
+ // Stop libfirefly dispatch first (via shared C++ registry),
84
+ // then drop the JVM-side lambda. Both lookups are tolerant
85
+ // of stale fires.
86
+ FireflyJniBridge.nativeUnregister(jniHandle)
87
+ FireflyJniBridge.unregister(jniHandle)
88
+ }
89
+
90
+ OnDestroy {
91
+ // Best-effort cleanup if the module is being torn down (hot
92
+ // reload, app shutdown). Drop every still-registered listener.
93
+ val pending = listeners.toMap()
94
+ listeners.clear()
95
+ for ((_, jniHandle) in pending) {
96
+ FireflyJniBridge.nativeUnregister(jniHandle)
97
+ FireflyJniBridge.unregister(jniHandle)
98
+ }
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,58 @@
1
+ package com.fireflydb.expo
2
+
3
+ import java.util.concurrent.ConcurrentHashMap
4
+ import java.util.concurrent.atomic.AtomicLong
5
+
6
+ /**
7
+ * JVM-side counterpart to libfirefly_jni.so. Holds a static dispatch
8
+ * table so the JNI thread (where the cross-platform listener registry
9
+ * fires its dispatcher on libfirefly's writer-thread callback) has a
10
+ * stable, lookup-by-handle path back into Kotlin.
11
+ *
12
+ * The shared C++ registry at `packages/expo/cpp/firefly_listener.cpp`
13
+ * owns the libfirefly C-ABI lifetime and the integer-handle dispatch.
14
+ * `nativeRegister`/`nativeUnregister` delegate to that registry; the
15
+ * `dispatch(handle, blob)` static is the JVM half — JNI calls it on
16
+ * fire, we look up the handle and invoke the registered Kotlin lambda.
17
+ *
18
+ * Stale fires after `unregister` are silent no-ops via lookup miss in
19
+ * BOTH this Kotlin table AND the shared C++ registry, which is how we
20
+ * honor the libfirefly contract that user_data outlive
21
+ * `firefly_remove_change_listener`.
22
+ */
23
+ internal object FireflyJniBridge {
24
+ init {
25
+ // Pulls in libfirefly_jni.so AND its link-time dependency
26
+ // libfirefly.so. Subsequent SQLite `loadExtension` calls on the
27
+ // same .so are idempotent; dlopen returns the same handle.
28
+ System.loadLibrary("firefly_jni")
29
+ }
30
+
31
+ private val listeners = ConcurrentHashMap<Long, (ByteArray) -> Unit>()
32
+ private val nextHandle = AtomicLong(1)
33
+
34
+ /** Register a Kotlin callback against a fresh handle. */
35
+ fun register(callback: (ByteArray) -> Unit): Long {
36
+ val handle = nextHandle.getAndIncrement()
37
+ listeners[handle] = callback
38
+ return handle
39
+ }
40
+
41
+ /** Stop dispatching to the lambda registered under `handle`. */
42
+ fun unregister(handle: Long) {
43
+ listeners.remove(handle)
44
+ }
45
+
46
+ /**
47
+ * Called from `firefly_jni.cpp` on the SQLite writer thread.
48
+ * Lookup-and-fire is intentionally cheap; the lambda usually just
49
+ * forwards via `sendEvent` and returns.
50
+ */
51
+ @JvmStatic
52
+ fun dispatch(handle: Long, blob: ByteArray) {
53
+ listeners[handle]?.invoke(blob)
54
+ }
55
+
56
+ external fun nativeRegister(path: String, handle: Long): Long
57
+ external fun nativeUnregister(handle: Long)
58
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["ios", "android"],
3
+ "ios": {
4
+ "modules": ["FireflyClientModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["com.fireflydb.expo.FireflyClientModule"]
8
+ }
9
+ }
@@ -0,0 +1,44 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>AvailableLibraries</key>
6
+ <array>
7
+ <dict>
8
+ <key>BinaryPath</key>
9
+ <string>Firefly.framework/Firefly</string>
10
+ <key>LibraryIdentifier</key>
11
+ <string>ios-arm64</string>
12
+ <key>LibraryPath</key>
13
+ <string>Firefly.framework</string>
14
+ <key>SupportedArchitectures</key>
15
+ <array>
16
+ <string>arm64</string>
17
+ </array>
18
+ <key>SupportedPlatform</key>
19
+ <string>ios</string>
20
+ </dict>
21
+ <dict>
22
+ <key>BinaryPath</key>
23
+ <string>Firefly.framework/Firefly</string>
24
+ <key>LibraryIdentifier</key>
25
+ <string>ios-arm64_x86_64-simulator</string>
26
+ <key>LibraryPath</key>
27
+ <string>Firefly.framework</string>
28
+ <key>SupportedArchitectures</key>
29
+ <array>
30
+ <string>arm64</string>
31
+ <string>x86_64</string>
32
+ </array>
33
+ <key>SupportedPlatform</key>
34
+ <string>ios</string>
35
+ <key>SupportedPlatformVariant</key>
36
+ <string>simulator</string>
37
+ </dict>
38
+ </array>
39
+ <key>CFBundlePackageType</key>
40
+ <string>XFWK</string>
41
+ <key>XCFrameworkFormatVersion</key>
42
+ <string>1.0</string>
43
+ </dict>
44
+ </plist>