diff --git a/.github/workflows/mobile-sdk-ci.yml b/.github/workflows/mobile-sdk-ci.yml
index e1f5d8e2..aee59a01 100644
--- a/.github/workflows/mobile-sdk-ci.yml
+++ b/.github/workflows/mobile-sdk-ci.yml
@@ -38,3 +38,41 @@ jobs:
- name: Test
run: cargo test
+
+ flutter-dart-gen:
+ name: Flutter Dart Binding Generation
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: client/mobile
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: '3.x'
+ channel: stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: client/mobile
+
+ - name: Install flutter_rust_bridge_codegen
+ run: cargo install flutter_rust_bridge_codegen
+
+ - name: Generate Dart bindings
+ run: |
+ mkdir -p flutter_dart_wrappers/lib/src/rust
+ flutter_rust_bridge_codegen generate \
+ --rust-input crate::api \
+ --rust-root flutter_ffi/ \
+ --dart-root flutter_dart_wrappers/ \
+ --dart-output flutter_dart_wrappers/lib/src/rust
+
+ - name: Verify generated files
+ run: |
+ test -f flutter_dart_wrappers/lib/src/rust/frb_generated.dart
+ echo "Dart bindings generated successfully"
diff --git a/.github/workflows/mobile-sdk-release.yml b/.github/workflows/mobile-sdk-release.yml
index 9551b91c..a34ac750 100644
--- a/.github/workflows/mobile-sdk-release.yml
+++ b/.github/workflows/mobile-sdk-release.yml
@@ -117,9 +117,65 @@ jobs:
path: client/mobile/android-sdk.tar.gz
retention-days: 5
+ build-flutter:
+ name: Build Flutter SDK
+ runs-on: macos-15
+ timeout-minutes: 40
+ defaults:
+ run:
+ working-directory: client/mobile
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - name: Add Apple build targets
+ run: rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin
+
+ - name: Add Android targets
+ run: rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
+
+ - name: Install cargo-ndk
+ run: cargo install cargo-ndk
+
+ - name: Setup Android NDK
+ uses: android-actions/setup-android@v3
+
+ - uses: subosito/flutter-action@v2
+ with:
+ flutter-version: '3.x'
+ channel: stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: client/mobile
+
+ - name: Install flutter_rust_bridge_codegen
+ run: cargo install flutter_rust_bridge_codegen
+
+ - name: Build Flutter SDK
+ run: ./build_flutter.sh
+
+ - name: Verify artifacts
+ run: |
+ test -f dist/flutter/lib/src/rust/frb_generated.dart
+ test -d dist/flutter/ios/rift_flutter_ffi.xcframework
+ test -d dist/flutter/android/jniLibs/arm64-v8a
+ echo "Flutter artifacts verified — frb_generated.dart, XCFramework, and Android jniLibs all present"
+
+ - name: Package Flutter SDK
+ run: tar -czf flutter-sdk.tar.gz -C dist flutter
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: flutter-sdk
+ path: client/mobile/flutter-sdk.tar.gz
+ retention-days: 5
+
release:
name: Create GitHub Release
- needs: [build-ios, build-android]
+ needs: [build-ios, build-android, build-flutter]
runs-on: ubuntu-latest
steps:
@@ -137,6 +193,11 @@ jobs:
name: android-sdk
path: artifacts/
+ - uses: actions/download-artifact@v4
+ with:
+ name: flutter-sdk
+ path: artifacts/
+
- name: Determine version
id: version
run: |
@@ -186,6 +247,7 @@ jobs:
TAG="${{ steps.version.outputs.tag }}"
mv artifacts/ios-sdk.tar.gz "artifacts/rift-ios-sdk-${TAG}.tar.gz"
mv artifacts/android-sdk.tar.gz "artifacts/rift-android-sdk-${TAG}.tar.gz"
+ mv artifacts/flutter-sdk.tar.gz "artifacts/rift-flutter-sdk-${TAG}.tar.gz"
# IMPORTANT — order matters here. Both the release tag and the SPM
# semver tag must point at the commit that has the URL+checksum
@@ -251,9 +313,19 @@ jobs:
### Android (Gradle)
Download `rift-android-sdk-${{ steps.version.outputs.tag }}.tar.gz`, extract into your project.
Contains: jniLibs (arm64-v8a, armeabi-v7a, x86, x86_64), Kotlin bindings, `build.gradle.kts`
+
+ ### Flutter / Dart
+ Download `rift-flutter-sdk-${{ steps.version.outputs.tag }}.tar.gz`, extract, and add as a path dependency:
+ ```yaml
+ dependencies:
+ rift_flutter_ffi:
+ path: ./rift_flutter_ffi
+ ```
+ Contains: generated Dart bindings, hand-written wrapper, iOS XCFramework, Android jniLibs.
files: |
artifacts/rift-ios-sdk-${{ steps.version.outputs.tag }}.tar.gz
artifacts/rift-android-sdk-${{ steps.version.outputs.tag }}.tar.gz
+ artifacts/rift-flutter-sdk-${{ steps.version.outputs.tag }}.tar.gz
artifacts/rift_ffiFFI.xcframework.zip
generate_release_notes: true
env:
diff --git a/client/mobile/Cargo.lock b/client/mobile/Cargo.lock
index d3d1fcb0..aec15c72 100644
--- a/client/mobile/Cargo.lock
+++ b/client/mobile/Cargo.lock
@@ -2,6 +2,21 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -11,6 +26,34 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "allo-isolate"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550"
+dependencies = [
+ "anyhow",
+ "atomic",
+ "backtrace",
+]
+
+[[package]]
+name = "android_log-sys"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
+
+[[package]]
+name = "android_logger"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
+dependencies = [
+ "android_log-sys",
+ "env_filter",
+ "log",
+]
+
[[package]]
name = "anstyle"
version = "1.0.14"
@@ -88,6 +131,12 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "atomic"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba"
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -100,6 +149,21 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -121,12 +185,39 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "build-target"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b"
+
[[package]]
name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
[[package]]
name = "bytes"
version = "1.11.1"
@@ -226,6 +317,48 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "dart-sys"
+version = "4.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "dashmap"
+version = "5.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
+dependencies = [
+ "cfg-if",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
[[package]]
name = "deadpool"
version = "0.12.3"
@@ -244,6 +377,27 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
+[[package]]
+name = "delegate-attr"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -255,6 +409,16 @@ dependencies = [
"syn",
]
+[[package]]
+name = "env_filter"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
+dependencies = [
+ "log",
+ "regex",
+]
+
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -283,6 +447,48 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "flutter_rust_bridge"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0884853aae8a6517b5b58cf36f55da487f2fe110e1686938eb29b6640aae4a5"
+dependencies = [
+ "allo-isolate",
+ "android_logger",
+ "anyhow",
+ "build-target",
+ "bytemuck",
+ "byteorder",
+ "console_error_panic_hook",
+ "dart-sys",
+ "delegate-attr",
+ "flutter_rust_bridge_macros",
+ "futures",
+ "js-sys",
+ "lazy_static",
+ "log",
+ "oslog",
+ "portable-atomic",
+ "threadpool",
+ "tokio",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "flutter_rust_bridge_macros"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b5ce32f35f710ced8c5aa557f023f1a624e737b5460cee2b70fcd3a8df09e1b"
+dependencies = [
+ "hex",
+ "md-5",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "fnv"
version = "1.0.7"
@@ -401,6 +607,16 @@ dependencies = [
"slab",
]
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -441,6 +657,12 @@ dependencies = [
"wasip3",
]
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
[[package]]
name = "glob"
version = "0.3.3"
@@ -477,6 +699,12 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -504,6 +732,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
[[package]]
name = "http"
version = "1.4.0"
@@ -794,6 +1028,15 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
[[package]]
name = "log"
version = "0.4.29"
@@ -815,6 +1058,16 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
[[package]]
name = "memchr"
version = "2.8.0"
@@ -827,6 +1080,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
[[package]]
name = "mio"
version = "1.1.1"
@@ -867,12 +1129,45 @@ dependencies = [
"libc",
]
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "oslog"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969"
+dependencies = [
+ "cc",
+ "dashmap",
+ "log",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -897,6 +1192,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1039,6 +1340,15 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
[[package]]
name = "regex"
version = "1.12.3"
@@ -1134,6 +1444,21 @@ dependencies = [
"wiremock",
]
+[[package]]
+name = "rift_flutter_ffi"
+version = "0.2.0"
+dependencies = [
+ "flutter_rust_bridge",
+ "reqwest",
+ "rift_sdk_core",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "uuid",
+]
+
[[package]]
name = "rift_mobile"
version = "0.2.0"
@@ -1166,6 +1491,12 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rustc-demangle"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
+
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -1232,6 +1563,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
[[package]]
name = "scroll"
version = "0.12.0"
@@ -1472,6 +1809,15 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "threadpool"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
+dependencies = [
+ "num_cpus",
+]
+
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -1667,6 +2013,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1855,6 +2207,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
name = "want"
version = "0.3.1"
diff --git a/client/mobile/Cargo.toml b/client/mobile/Cargo.toml
index 9c6d2f2b..fb748cfe 100644
--- a/client/mobile/Cargo.toml
+++ b/client/mobile/Cargo.toml
@@ -5,4 +5,5 @@ members = [
"ffi",
"mobile",
"uniffi-bindgen",
+ "flutter_ffi",
]
diff --git a/client/mobile/build_flutter.sh b/client/mobile/build_flutter.sh
new file mode 100755
index 00000000..913ed45b
--- /dev/null
+++ b/client/mobile/build_flutter.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# Build the Rift Flutter SDK:
+# - Generates Dart bindings via flutter_rust_bridge_codegen
+# - Builds native static libs for iOS targets → XCFramework
+# - Builds native shared libs for Android targets
+# - Packages everything into dist/flutter/
+set -euo pipefail
+cd "$(dirname "$0")"
+
+CRATE="rift_flutter_ffi"
+DIST="dist/flutter"
+HEADERS_DIR="$DIST/headers"
+IOS_DIST="$DIST/ios"
+ANDROID_DIST="$DIST/android"
+
+rm -rf "$DIST"
+mkdir -p "$HEADERS_DIR" "$IOS_DIST" "$ANDROID_DIST"
+
+# ── Step 1: Generate Dart bindings ──────────────────────────────────────────
+
+if ! command -v flutter_rust_bridge_codegen &>/dev/null; then
+ echo "[Rift] Installing flutter_rust_bridge_codegen..."
+ cargo install flutter_rust_bridge_codegen
+fi
+
+# frb codegen reads the Rust source and emits Dart bindings into the
+# flutter_dart_wrappers package. The generated file is included when we
+# copy flutter_dart_wrappers/ into dist/flutter/ below.
+mkdir -p flutter_dart_wrappers/lib/src/rust
+flutter_rust_bridge_codegen generate \
+ --rust-input "crate::api" \
+ --rust-root "flutter_ffi/" \
+ --dart-root "flutter_dart_wrappers/" \
+ --dart-output "flutter_dart_wrappers/lib/src/rust"
+
+echo "[Rift] Dart bindings generated → flutter_dart_wrappers/lib/src/rust/"
+
+# ── Step 2: Build iOS targets ────────────────────────────────────────────────
+
+APPLE_TARGETS=(aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin)
+for target in "${APPLE_TARGETS[@]}"; do
+ echo "[Rift] Building $target..."
+ cargo build --release --target "$target" -p "$CRATE"
+done
+
+# Bundle into XCFramework (device + simulator + macOS).
+xcodebuild -create-xcframework \
+ -library "target/aarch64-apple-ios/release/lib${CRATE}.a" \
+ -headers "$HEADERS_DIR" \
+ -library "target/aarch64-apple-ios-sim/release/lib${CRATE}.a" \
+ -headers "$HEADERS_DIR" \
+ -library "target/aarch64-apple-darwin/release/lib${CRATE}.a" \
+ -headers "$HEADERS_DIR" \
+ -output "$IOS_DIST/${CRATE}.xcframework"
+
+echo "[Rift] XCFramework → $IOS_DIST/${CRATE}.xcframework"
+
+# ── Step 3: Build Android targets ────────────────────────────────────────────
+
+if ! command -v cargo-ndk &>/dev/null; then
+ echo "[Rift] Installing cargo-ndk..."
+ cargo install cargo-ndk
+fi
+
+for target in aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android; do
+ case "$target" in
+ aarch64-linux-android) abi="arm64-v8a" ;;
+ armv7-linux-androideabi) abi="armeabi-v7a" ;;
+ i686-linux-android) abi="x86" ;;
+ x86_64-linux-android) abi="x86_64" ;;
+ esac
+ echo "[Rift] Building $target ($abi)..."
+ cargo ndk --target "$target" --platform 21 build --release -p "$CRATE"
+ mkdir -p "$ANDROID_DIST/jniLibs/$abi"
+ cp "target/$target/release/lib${CRATE}.so" "$ANDROID_DIST/jniLibs/$abi/"
+done
+
+echo "[Rift] Android libraries → $ANDROID_DIST/jniLibs/"
+
+# ── Step 4: Assemble the plugin package ──────────────────────────────────────
+
+# Copy hand-written Dart wrapper and plugin boilerplate.
+# Preserve the frb-generated lib/src/rust/ directory.
+cp -r flutter_dart_wrappers/lib/. "$DIST/lib/"
+cp flutter_dart_wrappers/pubspec.yaml "$DIST/"
+cp -r flutter_dart_wrappers/ios/Classes "$IOS_DIST/"
+cp flutter_dart_wrappers/ios/rift_flutter_ffi.podspec "$IOS_DIST/"
+# Place the XCFramework next to the podspec so CocoaPods can find it.
+# (Already built above into $IOS_DIST.)
+
+cp -r flutter_dart_wrappers/android/. "$ANDROID_DIST/"
+
+echo "[Rift] Flutter SDK assembled → $DIST"
+echo " To use as a path dependency in pubspec.yaml:"
+echo " rift_flutter_ffi:"
+echo " path: ./path/to/$DIST"
diff --git a/client/mobile/flutter_dart_wrappers/android/build.gradle b/client/mobile/flutter_dart_wrappers/android/build.gradle
new file mode 100644
index 00000000..52c2c8e3
--- /dev/null
+++ b/client/mobile/flutter_dart_wrappers/android/build.gradle
@@ -0,0 +1,54 @@
+group 'ink.riftl.sdk'
+version '0.2.0'
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:7.4.2'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.0"
+ }
+}
+
+rootProject.allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ compileSdkVersion 34
+ ndkVersion "25.2.9519653"
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ kotlinOptions {
+ jvmTarget = '1.8'
+ }
+
+ sourceSets {
+ main {
+ manifest.srcFile 'src/main/AndroidManifest.xml'
+ kotlin.srcDirs = ['src/main/kotlin']
+ // Prebuilt Rust shared libraries — placed by the release tarball.
+ jniLibs.srcDirs = ['jniLibs']
+ }
+ }
+
+ defaultConfig {
+ minSdkVersion 21
+ }
+}
+
+dependencies {
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0"
+}
diff --git a/client/mobile/flutter_dart_wrappers/android/src/main/AndroidManifest.xml b/client/mobile/flutter_dart_wrappers/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..25139f53
--- /dev/null
+++ b/client/mobile/flutter_dart_wrappers/android/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+
diff --git a/client/mobile/flutter_dart_wrappers/android/src/main/kotlin/ink/riftl/sdk/RiftFlutterFfiPlugin.kt b/client/mobile/flutter_dart_wrappers/android/src/main/kotlin/ink/riftl/sdk/RiftFlutterFfiPlugin.kt
new file mode 100644
index 00000000..2787cab2
--- /dev/null
+++ b/client/mobile/flutter_dart_wrappers/android/src/main/kotlin/ink/riftl/sdk/RiftFlutterFfiPlugin.kt
@@ -0,0 +1,10 @@
+package ink.riftl.sdk
+
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+
+// flutter_rust_bridge handles all FFI via dart:ffi.
+// This class only exists to satisfy Flutter's plugin registration mechanism.
+class RiftFlutterFfiPlugin : FlutterPlugin {
+ override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {}
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {}
+}
diff --git a/client/mobile/flutter_dart_wrappers/ios/Classes/RiftFlutterFfiPlugin.swift b/client/mobile/flutter_dart_wrappers/ios/Classes/RiftFlutterFfiPlugin.swift
new file mode 100644
index 00000000..ae442410
--- /dev/null
+++ b/client/mobile/flutter_dart_wrappers/ios/Classes/RiftFlutterFfiPlugin.swift
@@ -0,0 +1,7 @@
+import Flutter
+
+// flutter_rust_bridge handles all FFI via dart:ffi.
+// This class only exists to satisfy Flutter's plugin registration mechanism.
+public class RiftFlutterFfiPlugin: NSObject, FlutterPlugin {
+ public static func register(with registrar: FlutterPluginRegistrar) {}
+}
diff --git a/client/mobile/flutter_dart_wrappers/ios/rift_flutter_ffi.podspec b/client/mobile/flutter_dart_wrappers/ios/rift_flutter_ffi.podspec
new file mode 100644
index 00000000..9a5b3040
--- /dev/null
+++ b/client/mobile/flutter_dart_wrappers/ios/rift_flutter_ffi.podspec
@@ -0,0 +1,24 @@
+Pod::Spec.new do |s|
+ s.name = 'rift_flutter_ffi'
+ s.version = '0.2.0'
+ s.summary = 'Rift deep link SDK for Flutter'
+ s.description = 'Attribution, deferred deep linking, and conversion tracking via Rift.'
+ s.homepage = 'https://riftl.ink'
+ s.license = { type: 'MIT', file: '../LICENSE' }
+ s.author = { 'Rift' => 'hi@riftl.ink' }
+ s.source = { path: '.' }
+
+ s.source_files = 'Classes/**/*.swift'
+ s.swift_version = '5.0'
+ s.platform = :ios, '12.0'
+ s.dependency 'Flutter'
+
+ # Prebuilt XCFramework containing the Rust staticlib + C header.
+ # The release tarball places it alongside this podspec.
+ s.vendored_frameworks = 'rift_flutter_ffi.xcframework'
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386',
+ }
+end
diff --git a/client/mobile/flutter_dart_wrappers/lib/rift_sdk.dart b/client/mobile/flutter_dart_wrappers/lib/rift_sdk.dart
new file mode 100644
index 00000000..d705fafb
--- /dev/null
+++ b/client/mobile/flutter_dart_wrappers/lib/rift_sdk.dart
@@ -0,0 +1,21 @@
+/// Rift Flutter SDK — deep link attribution, deferred deep linking, conversion tracking.
+///
+/// Quick start:
+/// ```dart
+/// final rift = await RiftSdk.create(publishableKey: 'pk_live_...');
+///
+/// // Bind user after sign-in
+/// await rift.setUserId('user_123');
+///
+/// // Check for deferred deep link on first stable screen (not at launch)
+/// final text = (await Clipboard.getData('text/plain'))?.text;
+/// final result = await rift.checkDeferredDeepLink(clipboardText: text);
+/// if (result != null) { /* navigate */ }
+///
+/// // Fire a conversion event
+/// await rift.trackConversion(type: 'purchase', idempotencyKey: 'order_xyz');
+/// ```
+library rift_flutter_ffi;
+
+export 'src/rift_sdk_impl.dart';
+export 'src/rift_storage.dart';
diff --git a/client/mobile/flutter_dart_wrappers/lib/src/rift_sdk_impl.dart b/client/mobile/flutter_dart_wrappers/lib/src/rift_sdk_impl.dart
new file mode 100644
index 00000000..f09f55d1
--- /dev/null
+++ b/client/mobile/flutter_dart_wrappers/lib/src/rift_sdk_impl.dart
@@ -0,0 +1,124 @@
+import 'package:rift_flutter_ffi/src/rift_storage.dart';
+import 'package:rift_flutter_ffi/src/rust/frb_generated.dart';
+
+export 'package:rift_flutter_ffi/src/rust/frb_generated.dart'
+ show ClickResult, GetLinkResult, DeferredDeepLinkResult;
+
+const _kInstallId = 'rift.install_id';
+const _kUserId = 'rift.user_id';
+const _kUserIdSynced = 'rift.user_id_synced';
+
+/// Rift SDK for Flutter. Obtain via [RiftSdk.create].
+///
+/// The SDK owns all attribution logic. The [RiftStorage] implementation you
+/// provide owns persistence — the SDK reads initial state from storage at
+/// construction and writes back after every state-mutating operation.
+class RiftSdk {
+ final RiftSdkRust _sdk;
+ final RiftStorage _storage;
+
+ RiftSdk._(this._sdk, this._storage);
+
+ /// Initialize the SDK. Call once at app startup, before calling any other
+ /// method. Loads persisted state (install_id, user binding) from [storage]
+ /// and retries any unsynced user binding in the background.
+ ///
+ /// [storage] defaults to [SharedPrefsRiftStorage] when omitted.
+ static Future create({
+ required String publishableKey,
+ RiftStorage? storage,
+ String? baseUrl,
+ String? logLevel,
+ String? appVersion,
+ }) async {
+ await RustLib.init();
+
+ final st = storage ?? await SharedPrefsRiftStorage.create();
+ final installId = await st.get(_kInstallId);
+ final userId = await st.get(_kUserId);
+ final userIdSynced = await st.get(_kUserIdSynced) == 'true';
+
+ final sdk = await RiftSdkRust.create(
+ config: RiftConfig(
+ publishableKey: publishableKey,
+ baseUrl: baseUrl,
+ logLevel: logLevel,
+ appVersion: appVersion,
+ ),
+ state: installId != null
+ ? RiftState(
+ installId: installId,
+ userId: userId,
+ userIdSynced: userIdSynced,
+ )
+ : null,
+ );
+
+ // Persist a newly-generated install_id so it survives the next launch.
+ final current = sdk.getState();
+ if (current.installId != installId) {
+ await st.set(_kInstallId, current.installId);
+ }
+
+ return RiftSdk._(sdk, st);
+ }
+
+ /// The persistent install ID for this device.
+ String get installId => _sdk.getInstallId();
+
+ /// Bind a user ID to this install. Call after the user signs in. Safe to
+ /// call on every launch with the same user_id — it no-ops if already synced.
+ Future setUserId(String userId) async {
+ final state = await _sdk.setUserId(userId: userId);
+ await _persistState(state);
+ }
+
+ /// Clear the bound user ID. Call on logout. install_id is preserved.
+ Future clearUserId() async {
+ final state = _sdk.clearUserId();
+ await _persistState(state);
+ }
+
+ /// Resolve a link and return routing destinations.
+ Future click(String linkId) => _sdk.click(linkId: linkId);
+
+ /// Report attribution for this install. Returns true on success.
+ Future attributeLink(String linkId) => _sdk.attributeLink(linkId: linkId);
+
+ /// Fetch link routing destinations without recording a click.
+ Future getLink(String linkId) => _sdk.getLink(linkId: linkId);
+
+ /// Parse clipboard text for a Rift link, report attribution, and return link
+ /// data for navigation. Returns null if no Rift link is found.
+ ///
+ /// Call this on your first stable screen (home/dashboard), NOT at cold start
+ /// while the user is still in onboarding.
+ Future checkDeferredDeepLink({
+ required String? clipboardText,
+ }) =>
+ _sdk.checkDeferredDeepLink(clipboardText: clipboardText);
+
+ /// Fire a conversion event (purchase, signup, etc.). No-op if no user is bound.
+ ///
+ /// [metadata] is an optional JSON string of arbitrary key-value pairs.
+ Future trackConversion({
+ required String type,
+ required String idempotencyKey,
+ String? metadata,
+ }) =>
+ _sdk.trackConversion(
+ conversionType: type,
+ idempotencyKey: idempotencyKey,
+ metadata: metadata,
+ );
+
+ Future _persistState(RiftState state) async {
+ await _storage.set(_kInstallId, state.installId);
+ if (state.userId != null) {
+ await _storage.set(_kUserId, state.userId!);
+ } else {
+ await _storage.remove(_kUserId);
+ }
+ await _storage.set(_kUserIdSynced, state.userIdSynced ? 'true' : 'false');
+ }
+}
diff --git a/client/mobile/flutter_dart_wrappers/lib/src/rift_storage.dart b/client/mobile/flutter_dart_wrappers/lib/src/rift_storage.dart
new file mode 100644
index 00000000..96d39858
--- /dev/null
+++ b/client/mobile/flutter_dart_wrappers/lib/src/rift_storage.dart
@@ -0,0 +1,39 @@
+import 'package:shared_preferences/shared_preferences.dart';
+
+/// Storage backend contract. Implement this to use your preferred storage
+/// (FlutterSecureStorage, Hive, encrypted SharedPreferences, etc.).
+///
+/// The default implementation, [SharedPrefsRiftStorage], uses
+/// `shared_preferences` and is suitable for most apps. On Android this
+/// survives app updates but not reinstalls. On iOS use [FlutterSecureStorage]
+/// for Keychain-backed persistence across reinstalls.
+abstract class RiftStorage {
+ Future get(String key);
+ Future set(String key, String value);
+ Future remove(String key);
+}
+
+/// Default storage backed by `shared_preferences`.
+class SharedPrefsRiftStorage implements RiftStorage {
+ final SharedPreferences _prefs;
+
+ const SharedPrefsRiftStorage(this._prefs);
+
+ static Future create() async {
+ final prefs = await SharedPreferences.getInstance();
+ return SharedPrefsRiftStorage(prefs);
+ }
+
+ @override
+ Future get(String key) async => _prefs.getString(key);
+
+ @override
+ Future set(String key, String value) async {
+ await _prefs.setString(key, value);
+ }
+
+ @override
+ Future remove(String key) async {
+ await _prefs.remove(key);
+ }
+}
diff --git a/client/mobile/flutter_dart_wrappers/pubspec.yaml b/client/mobile/flutter_dart_wrappers/pubspec.yaml
new file mode 100644
index 00000000..59877e3e
--- /dev/null
+++ b/client/mobile/flutter_dart_wrappers/pubspec.yaml
@@ -0,0 +1,30 @@
+name: rift_flutter_ffi
+description: Rift deep link SDK for Flutter — attribution, deferred deep linking, conversion tracking.
+version: 0.2.0
+homepage: https://riftl.ink
+
+environment:
+ sdk: '>=3.0.0 <4.0.0'
+ flutter: '>=3.10.0'
+
+flutter:
+ plugin:
+ platforms:
+ android:
+ ffiPlugin: true
+ ios:
+ ffiPlugin: true
+
+dependencies:
+ flutter:
+ sdk: flutter
+ flutter_rust_bridge: '>=2.0.0 <3.0.0'
+ freezed_annotation: ^2.4.1
+ meta: ^1.11.0
+ shared_preferences: ^2.2.0
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ build_runner: ^2.4.8
+ freezed: ^2.4.6
diff --git a/client/mobile/flutter_ffi/Cargo.toml b/client/mobile/flutter_ffi/Cargo.toml
new file mode 100644
index 00000000..7b4f96ca
--- /dev/null
+++ b/client/mobile/flutter_ffi/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "rift_flutter_ffi"
+version = "0.2.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "staticlib"]
+
+[dependencies]
+rift_sdk_core = { path = "../core" }
+flutter_rust_bridge = "2"
+tokio = { version = "1", features = ["rt", "rt-multi-thread"] }
+thiserror = "2"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+uuid = { version = "1", features = ["v4"] }
+serde_json = "1"
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+
+[dev-dependencies]
+tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
diff --git a/client/mobile/flutter_ffi/build.rs b/client/mobile/flutter_ffi/build.rs
new file mode 100644
index 00000000..8febff10
--- /dev/null
+++ b/client/mobile/flutter_ffi/build.rs
@@ -0,0 +1,6 @@
+fn main() {
+ // flutter_rust_bridge's proc macros emit `cfg(frb_expand)` internally.
+ // Declaring it here avoids the "unexpected cfg condition" warning when
+ // building without having run `flutter_rust_bridge_codegen generate` first.
+ println!("cargo::rustc-check-cfg=cfg(frb_expand)");
+}
diff --git a/client/mobile/flutter_ffi/src/api.rs b/client/mobile/flutter_ffi/src/api.rs
new file mode 100644
index 00000000..fd52c147
--- /dev/null
+++ b/client/mobile/flutter_ffi/src/api.rs
@@ -0,0 +1,431 @@
+use flutter_rust_bridge::frb;
+use rift_sdk_core::client::RiftClient;
+use rift_sdk_core::error::RiftError as CoreError;
+use std::sync::{Arc, Mutex, Once};
+use tracing_subscriber::layer::SubscriberExt;
+use tracing_subscriber::util::SubscriberInitExt;
+
+// ── Init ──
+
+static LOGGING_INIT: Once = Once::new();
+
+#[frb(init)]
+pub fn init_app() {
+ // flutter_rust_bridge manages the async runtime internally via the generated
+ // bridge code. User initialization (logging, etc.) happens in RiftSdk::create().
+}
+
+fn init_logging(level: &str) {
+ LOGGING_INIT.call_once(|| {
+ let filter = tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
+ let fmt_layer = tracing_subscriber::fmt::layer()
+ .with_ansi(false)
+ .with_file(false)
+ .with_line_number(true)
+ .with_thread_ids(true)
+ .with_target(true)
+ .compact();
+ let result = tracing_subscriber::registry()
+ .with(filter)
+ .with(fmt_layer)
+ .try_init();
+ match result {
+ Ok(_) => eprintln!("[Rift Flutter SDK] Logging initialized (level: {level})"),
+ Err(e) => eprintln!("[Rift Flutter SDK] Logging init skipped: {e}"),
+ }
+ });
+}
+
+// ── Error ──
+
+#[derive(Debug, thiserror::Error)]
+pub enum RiftError {
+ #[error("Network error: {message}")]
+ Network { message: String },
+
+ #[error("API error ({status}): {message}")]
+ Api { status: u16, message: String },
+
+ #[error("Deserialization error: {message}")]
+ Deserialize { message: String },
+}
+
+impl From for RiftError {
+ fn from(e: CoreError) -> Self {
+ match e {
+ CoreError::Network(msg) => RiftError::Network { message: msg },
+ CoreError::Api { status, message } => RiftError::Api { status, message },
+ CoreError::Deserialize(msg) => RiftError::Deserialize { message: msg },
+ }
+ }
+}
+
+// ── Config + State ──
+
+pub struct RiftConfig {
+ pub publishable_key: String,
+ pub base_url: Option,
+ /// "trace" | "debug" | "info" | "warn" | "error". Default: "info".
+ pub log_level: Option,
+ /// App version string (e.g. "1.2.3"). Defaults to "unknown".
+ pub app_version: Option,
+}
+
+/// Snapshot of SDK state that the Dart layer must persist across launches.
+/// The Dart wrapper loads this from storage before calling `RiftSdk.create`
+/// and writes it back whenever a state-mutating method returns a new snapshot.
+pub struct RiftState {
+ pub install_id: String,
+ pub user_id: Option,
+ pub user_id_synced: bool,
+}
+
+// ── Response types ──
+
+pub struct ClickResult {
+ pub link_id: String,
+ pub platform: String,
+ pub ios_deep_link: Option,
+ pub android_deep_link: Option,
+ pub web_url: Option,
+ pub ios_store_url: Option,
+ pub android_store_url: Option,
+ /// JSON string of arbitrary link metadata, or None.
+ pub metadata: Option,
+}
+
+pub struct GetLinkResult {
+ pub link_id: String,
+ pub ios_deep_link: Option,
+ pub android_deep_link: Option,
+ pub web_url: Option,
+ pub ios_store_url: Option,
+ pub android_store_url: Option,
+ pub metadata: Option,
+}
+
+pub struct DeferredDeepLinkResult {
+ pub link_id: String,
+ pub ios_deep_link: Option,
+ pub android_deep_link: Option,
+ pub web_url: Option,
+ pub metadata: Option,
+}
+
+// ── SDK ──
+
+struct Inner {
+ client: RiftClient,
+ install_id: Mutex,
+ user_id: Mutex