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>, + user_id_synced: Mutex, + api_base_url: String, + publishable_key: String, + app_version: String, +} + +/// Main Rift SDK object. Obtain via `RiftSdk.create(...)`. +/// +/// This crate uses flutter_rust_bridge for Dart/Flutter bindings. +/// Storage is owned by the Dart layer: load `RiftState` from your preferred +/// storage backend before calling `create`, and persist the returned `RiftState` +/// after any call that mutates state (`setUserId`, `clearUserId`). +#[frb(opaque)] +pub struct RiftSdk { + inner: Arc, +} + +impl RiftSdk { + /// Construct a new SDK instance. Load persisted state from Dart storage and + /// pass it here; if `None` (first launch), a new install_id is generated. + /// Check `getState().install_id` after construction and persist if it changed. + pub async fn create(config: RiftConfig, state: Option) -> RiftSdk { + let level = config.log_level.as_deref().unwrap_or("info"); + init_logging(level); + + let install_id = state + .as_ref() + .map(|s| s.install_id.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + let user_id = state.as_ref().and_then(|s| s.user_id.clone()); + let user_id_synced = state.as_ref().map(|s| s.user_id_synced).unwrap_or(false); + + let api_base_url = config + .base_url + .clone() + .unwrap_or_else(|| "https://api.riftl.ink".to_string()); + let publishable_key = config.publishable_key.clone(); + + let inner = Arc::new(Inner { + client: RiftClient::new(config.publishable_key, config.base_url), + install_id: Mutex::new(install_id), + user_id: Mutex::new(user_id), + user_id_synced: Mutex::new(user_id_synced), + api_base_url, + publishable_key, + app_version: config.app_version.unwrap_or_else(|| "unknown".to_string()), + }); + + // Retry pending user binding from a previous session. + let needs_retry = matches!( + ( + inner.user_id.lock().unwrap().clone(), + *inner.user_id_synced.lock().unwrap(), + ), + (Some(_), false) + ); + if needs_retry { + let inner_bg = Arc::clone(&inner); + tokio::spawn(async move { + if let Err(e) = retry_pending_binding(&inner_bg).await { + tracing::debug!(error = ?e, "pending binding retry failed"); + } + }); + } + + RiftSdk { inner } + } + + /// Current SDK state snapshot. Persist this after construction to save a + /// newly-generated install_id, and after any state-mutating call. + #[frb(sync)] + pub fn get_state(&self) -> RiftState { + snapshot(&self.inner) + } + + /// The persistent install ID. Stable across launches; on iOS (with + /// KeychainStorage) stable across reinstalls. + #[frb(sync)] + pub fn get_install_id(&self) -> String { + self.inner.install_id.lock().unwrap().clone() + } + + /// Bind a user ID to this install. Persists locally and calls the server. + /// On network failure the binding is left as unsynced and retried on the + /// next `create` call. Returns the new state to persist. + pub async fn set_user_id(&self, user_id: String) -> Result { + if user_id.trim().is_empty() { + return Err(RiftError::Api { + status: 400, + message: "user_id must not be empty".to_string(), + }); + } + + let existing = self.inner.user_id.lock().unwrap().clone(); + let synced = *self.inner.user_id_synced.lock().unwrap(); + if existing.as_deref() == Some(user_id.as_str()) && synced { + return Ok(snapshot(&self.inner)); + } + + *self.inner.user_id.lock().unwrap() = Some(user_id.clone()); + *self.inner.user_id_synced.lock().unwrap() = false; + + let install_id = self.inner.install_id.lock().unwrap().clone(); + match self + .inner + .client + .identify(install_id, user_id.clone()) + .await + { + Ok(_) => { + *self.inner.user_id_synced.lock().unwrap() = true; + Ok(snapshot(&self.inner)) + } + Err(CoreError::Api { status, .. }) if status == 400 || status == 404 => { + tracing::warn!( + status, + "identify permanently rejected; clearing pending state" + ); + *self.inner.user_id.lock().unwrap() = None; + *self.inner.user_id_synced.lock().unwrap() = false; + Err(RiftError::Api { + status, + message: "User binding rejected by server".to_string(), + }) + } + Err(e) => { + tracing::warn!(error = ?e, "identify failed; will retry on next launch"); + Err(e.into()) + } + } + } + + /// Clear the bound user ID (call on logout). Returns the new state to persist. + #[frb(sync)] + pub fn clear_user_id(&self) -> RiftState { + *self.inner.user_id.lock().unwrap() = None; + *self.inner.user_id_synced.lock().unwrap() = false; + snapshot(&self.inner) + } + + /// Resolve a link and return routing destinations. + pub async fn click(&self, link_id: String) -> Result { + let resp = self.inner.client.click(link_id).await?; + Ok(ClickResult { + link_id: resp.link_id, + platform: resp.platform, + ios_deep_link: resp.ios_deep_link, + android_deep_link: resp.android_deep_link, + web_url: resp.web_url, + ios_store_url: resp.ios_store_url, + android_store_url: resp.android_store_url, + metadata: resp.metadata.map(|v| v.to_string()), + }) + } + + /// Report attribution for this install. Returns `true` on success. + pub async fn attribute_link(&self, link_id: String) -> Result { + let install_id = self.inner.install_id.lock().unwrap().clone(); + let app_version = self.inner.app_version.clone(); + Ok(self + .inner + .client + .attribute(link_id, install_id, app_version) + .await?) + } + + /// Fetch link routing destinations without recording a click. + pub async fn get_link(&self, link_id: String) -> Result { + let resp = self.inner.client.get_link(link_id).await?; + Ok(GetLinkResult { + link_id: resp.link_id, + ios_deep_link: resp.ios_deep_link, + android_deep_link: resp.android_deep_link, + web_url: resp.web_url, + ios_store_url: resp.ios_store_url, + android_store_url: resp.android_store_url, + metadata: resp.metadata.map(|v| v.to_string()), + }) + } + + /// Parse clipboard text for a Rift link, report attribution if found, and + /// return link data for navigation. Pass `None` if clipboard is empty. + /// + /// The caller must read the clipboard themselves — the SDK does not request + /// clipboard permission directly. + pub async fn check_deferred_deep_link( + &self, + clipboard_text: Option, + ) -> Result, RiftError> { + let Some(text) = clipboard_text else { + return Ok(None); + }; + let Some(link_id) = rift_sdk_core::parser::parse_clipboard_link(&text) else { + return Ok(None); + }; + + if let Err(e) = self.attribute_link(link_id.clone()).await { + tracing::warn!(error = ?e, "deferred deep link attribution failed"); + } + + match self.inner.client.get_link(link_id.clone()).await { + Ok(resp) => Ok(Some(DeferredDeepLinkResult { + link_id, + ios_deep_link: resp.ios_deep_link, + android_deep_link: resp.android_deep_link, + web_url: resp.web_url, + metadata: resp.metadata.map(|v| v.to_string()), + })), + Err(e) => { + tracing::warn!(error = ?e, "deferred deep link fetch failed"); + Err(e.into()) + } + } + } + + /// Fire a conversion event. No-op (with a warning) if no user_id is bound. + pub async fn track_conversion( + &self, + conversion_type: String, + idempotency_key: String, + metadata: Option, + ) -> Result<(), RiftError> { + let user_id = self.inner.user_id.lock().unwrap().clone(); + let Some(user_id) = user_id else { + tracing::warn!("track_conversion called but no user_id bound — call setUserId first"); + return Ok(()); + }; + + let mut payload = serde_json::json!({ + "user_id": user_id, + "type": conversion_type, + "idempotency_key": idempotency_key, + }); + if let Some(meta_str) = metadata { + if let Ok(meta_val) = serde_json::from_str::(&meta_str) { + payload["metadata"] = meta_val; + } + } + + let url = format!( + "{}/v1/lifecycle/convert", + self.inner.api_base_url.trim_end_matches('/') + ); + let http = reqwest::Client::new(); + match http + .post(&url) + .header( + "Authorization", + format!("Bearer {}", self.inner.publishable_key), + ) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await + { + Ok(r) => tracing::debug!(status = %r.status(), "conversion event sent"), + Err(e) => tracing::warn!(error = %e, "conversion event failed"), + } + + Ok(()) + } +} + +// ── Free functions ── + +/// Extract a Rift link_id from a clipboard string (URL or "rift:" format). +pub fn parse_clipboard_link(text: String) -> Option { + rift_sdk_core::parser::parse_clipboard_link(&text) +} + +/// Extract a Rift link_id from an Android install referrer query string. +pub fn parse_referrer_link(referrer: String) -> Option { + rift_sdk_core::parser::parse_referrer_link(&referrer) +} + +// ── Helpers ── + +fn snapshot(inner: &Inner) -> RiftState { + RiftState { + install_id: inner.install_id.lock().unwrap().clone(), + user_id: inner.user_id.lock().unwrap().clone(), + user_id_synced: *inner.user_id_synced.lock().unwrap(), + } +} + +async fn retry_pending_binding(inner: &Inner) -> Result<(), RiftError> { + let user_id = inner.user_id.lock().unwrap().clone(); + let synced = *inner.user_id_synced.lock().unwrap(); + + let Some(user_id) = user_id else { + return Ok(()); + }; + if synced { + return Ok(()); + } + + let install_id = inner.install_id.lock().unwrap().clone(); + inner.client.identify(install_id, user_id.clone()).await?; + + // Only mark synced if the stored user_id is still the same (guard against + // a concurrent set_user_id call). + let current = inner.user_id.lock().unwrap().clone(); + if current.as_deref() == Some(user_id.as_str()) { + *inner.user_id_synced.lock().unwrap() = true; + } + + Ok(()) +} diff --git a/client/mobile/flutter_ffi/src/lib.rs b/client/mobile/flutter_ffi/src/lib.rs new file mode 100644 index 00000000..f933c52c --- /dev/null +++ b/client/mobile/flutter_ffi/src/lib.rs @@ -0,0 +1,2 @@ +// frb codegen scans `crate::api` for the public API surface. +pub mod api;